From df38cf07b3a2ed9c79d4122653b693ab5a4e6727 Mon Sep 17 00:00:00 2001 From: TenzDelek <122612557+TenzDelek@users.noreply.github.com> Date: Wed, 27 May 2026 09:36:47 +0530 Subject: [PATCH 01/29] Segment cleanup (#631) * remove dead import * remove the traslation and comentary * root_text on segment leve * remove root_text legacy * remove segment_info * get segment detail * remove legacy get by id * remove the segment detail service * remove put and post partial * cleanup * fix segment fetch * drop sheet concept * drop text uploader * remove config * remove mapping * remove all * remvoe group * Revert "remvoe group" This reverts commit 72925d1298da8598cc5c504eaaf3603608cbf047. * Revert "remove all" This reverts commit c24962ae7e625c8340a678d23e32ac2b3e6c58ac. * test --- documentation/developer/api_overview.md | 2 +- documentation/developer/architecture.md | 4 - .../segments/openpecha_segment_service.py | 7 + pecha_api/app.py | 12 - pecha_api/cache/cache_enums.py | 9 - pecha_api/config.py | 4 - pecha_api/error_contants.py | 2 - pecha_api/recitations/recitations_services.py | 9 +- pecha_api/share/share_service.py | 18 +- pecha_api/sheets/__init__.py | 0 pecha_api/sheets/sheets_enum.py | 9 - pecha_api/sheets/sheets_models.py | 0 pecha_api/sheets/sheets_repository.py | 0 pecha_api/sheets/sheets_response_models.py | 80 - pecha_api/sheets/sheets_service.py | 620 ----- pecha_api/sheets/sheets_views.py | 101 - .../collections/collection_model.py | 12 - .../collections/collection_service.py | 276 --- .../collections/collections_repository.py | 101 - .../collections/uploader_collections_views.py | 15 - pecha_api/text_uploader/constants.py | 35 - .../text_uploader/mapping/mapping_model.py | 8 - .../mapping/mapping_repository.py | 26 - .../text_uploader/mapping/mapping_services.py | 11 - pecha_api/text_uploader/pipeline.py | 70 - .../text_uploader/segments/segment_model.py | 15 - .../segments/segment_respository.py | 135 - .../text_uploader/segments/segment_service.py | 103 - .../table_of_content/toc_repository.py | 13 - .../table_of_content/toc_service.py | 57 - .../text_metadata/text_group_repository.py | 153 -- .../text_metadata/text_metadata_model.py | 37 - .../text_metadata/text_metadata_service.py | 170 -- .../text_metadata/text_metadata_views.py | 16 - .../text_uploader_response_model.py | 11 - .../text_uploader/text_uploader_views.py | 21 - pecha_api/texts/mappings/__init__.py | 0 .../texts/mappings/mappings_repository.py | 30 - .../mappings/mappings_response_models.py | 17 - pecha_api/texts/mappings/mappings_service.py | 272 --- pecha_api/texts/mappings/mappings_views.py | 27 - .../texts/segments/segments_cache_service.py | 84 +- pecha_api/texts/segments/segments_models.py | 28 - .../segments/segments_openpecha_service.py | 120 + .../segments/segments_openpecha_views.py | 39 +- .../texts/segments/segments_repository.py | 50 +- .../segments/segments_response_models.py | 78 +- pecha_api/texts/segments/segments_service.py | 204 +- pecha_api/texts/segments/segments_utils.py | 108 +- pecha_api/texts/segments/segments_views.py | 87 - pecha_api/texts/texts_cache_service.py | 76 +- pecha_api/texts/texts_enums.py | 10 + pecha_api/texts/texts_models.py | 58 - pecha_api/texts/texts_repository.py | 23 - pecha_api/texts/texts_service.py | 48 +- tests/collection/test_collection_views.py | 47 - .../recitations/test_recitations_services.py | 8 +- tests/segments/test_segments_cache_service.py | 183 -- .../test_segments_openpecha_service.py | 288 ++- .../segments/test_segments_openpecha_views.py | 97 +- tests/segments/test_segments_service.py | 707 +----- tests/segments/test_segments_utils.py | 472 ---- tests/segments/test_segments_views.py | 341 --- tests/share/test_share_service.py | 82 +- tests/sheets/test_sheets_service.py | 2176 ----------------- tests/sheets/test_sheets_views.py | 149 -- tests/text_uploader/__init__.py | 6 - tests/text_uploader/collections/__init__.py | 6 - .../collections/test_collection_service.py | 701 ------ .../mapping/test_mapping_service.py | 30 - .../mapping/test_mapping_services.py | 31 - tests/text_uploader/segments/__init__.py | 6 - .../segments/test_segment_model.py | 27 - .../segments/test_segment_repository.py | 218 -- .../segments/test_segment_service.py | 221 -- tests/text_uploader/test_pipeline.py | 158 -- .../text_uploader/test_text_uploader_views.py | 181 -- tests/text_uploader/texts/__init__.py | 6 - .../texts/test_text_metadata_service.py | 573 ----- tests/text_uploader/texts/test_toc_service.py | 105 - tests/texts/mappings/__init__.py | 0 tests/texts/mappings/test_delete_mappings.py | 157 -- tests/texts/mappings/test_mapping_updates.py | 164 -- tests/texts/mappings/test_mappings_service.py | 199 -- tests/texts/mappings/test_mappings_views.py | 142 -- tests/texts/test_text_cache_service.py | 173 -- tests/texts/test_text_repository.py | 261 -- tests/texts/test_texts_service.py | 200 +- tests/texts/test_texts_views.py | 292 --- 89 files changed, 630 insertions(+), 11298 deletions(-) delete mode 100644 pecha_api/sheets/__init__.py delete mode 100644 pecha_api/sheets/sheets_enum.py delete mode 100644 pecha_api/sheets/sheets_models.py delete mode 100644 pecha_api/sheets/sheets_repository.py delete mode 100644 pecha_api/sheets/sheets_response_models.py delete mode 100644 pecha_api/sheets/sheets_service.py delete mode 100644 pecha_api/sheets/sheets_views.py delete mode 100644 pecha_api/text_uploader/collections/collection_model.py delete mode 100644 pecha_api/text_uploader/collections/collection_service.py delete mode 100644 pecha_api/text_uploader/collections/collections_repository.py delete mode 100644 pecha_api/text_uploader/collections/uploader_collections_views.py delete mode 100644 pecha_api/text_uploader/constants.py delete mode 100644 pecha_api/text_uploader/mapping/mapping_model.py delete mode 100644 pecha_api/text_uploader/mapping/mapping_repository.py delete mode 100644 pecha_api/text_uploader/mapping/mapping_services.py delete mode 100644 pecha_api/text_uploader/pipeline.py delete mode 100644 pecha_api/text_uploader/segments/segment_model.py delete mode 100644 pecha_api/text_uploader/segments/segment_respository.py delete mode 100644 pecha_api/text_uploader/segments/segment_service.py delete mode 100644 pecha_api/text_uploader/table_of_content/toc_repository.py delete mode 100644 pecha_api/text_uploader/table_of_content/toc_service.py delete mode 100644 pecha_api/text_uploader/text_metadata/text_group_repository.py delete mode 100644 pecha_api/text_uploader/text_metadata/text_metadata_model.py delete mode 100644 pecha_api/text_uploader/text_metadata/text_metadata_service.py delete mode 100644 pecha_api/text_uploader/text_metadata/text_metadata_views.py delete mode 100644 pecha_api/text_uploader/text_uploader_response_model.py delete mode 100644 pecha_api/text_uploader/text_uploader_views.py delete mode 100644 pecha_api/texts/mappings/__init__.py delete mode 100644 pecha_api/texts/mappings/mappings_repository.py delete mode 100644 pecha_api/texts/mappings/mappings_response_models.py delete mode 100644 pecha_api/texts/mappings/mappings_service.py delete mode 100644 pecha_api/texts/mappings/mappings_views.py delete mode 100644 pecha_api/texts/segments/segments_views.py delete mode 100644 tests/segments/test_segments_cache_service.py delete mode 100644 tests/segments/test_segments_views.py delete mode 100644 tests/sheets/test_sheets_service.py delete mode 100644 tests/sheets/test_sheets_views.py delete mode 100644 tests/text_uploader/__init__.py delete mode 100644 tests/text_uploader/collections/__init__.py delete mode 100644 tests/text_uploader/collections/test_collection_service.py delete mode 100644 tests/text_uploader/mapping/test_mapping_service.py delete mode 100644 tests/text_uploader/mapping/test_mapping_services.py delete mode 100644 tests/text_uploader/segments/__init__.py delete mode 100644 tests/text_uploader/segments/test_segment_model.py delete mode 100644 tests/text_uploader/segments/test_segment_repository.py delete mode 100644 tests/text_uploader/segments/test_segment_service.py delete mode 100644 tests/text_uploader/test_pipeline.py delete mode 100644 tests/text_uploader/test_text_uploader_views.py delete mode 100644 tests/text_uploader/texts/__init__.py delete mode 100644 tests/text_uploader/texts/test_text_metadata_service.py delete mode 100644 tests/text_uploader/texts/test_toc_service.py delete mode 100644 tests/texts/mappings/__init__.py delete mode 100644 tests/texts/mappings/test_delete_mappings.py delete mode 100644 tests/texts/mappings/test_mapping_updates.py delete mode 100644 tests/texts/mappings/test_mappings_service.py delete mode 100644 tests/texts/mappings/test_mappings_views.py delete mode 100644 tests/texts/test_text_repository.py diff --git a/documentation/developer/api_overview.md b/documentation/developer/api_overview.md index f89a58aae..73a1f7961 100644 --- a/documentation/developer/api_overview.md +++ b/documentation/developer/api_overview.md @@ -11,7 +11,7 @@ The API root path is `/api/v1` (see `pecha_api/app.py`). Routers are included in `pecha_api/app.py`: - Auth: `pecha_api/auth/auth_views.py` -- Core: texts, groups, segments, mappings, topics, users, collections, terms, sheets +- Core: texts, groups, segments, topics, users, collections, terms - Search: `pecha_api/search/search_views.py` - Plans: public, users, CMS, items, tasks, featured - Share: `pecha_api/share/share_views.py` diff --git a/documentation/developer/architecture.md b/documentation/developer/architecture.md index c951e2fd2..a858c612d 100644 --- a/documentation/developer/architecture.md +++ b/documentation/developer/architecture.md @@ -22,7 +22,6 @@ Routers included in `pecha_api/app.py`: - `texts`: `pecha_api/texts/texts_views.py` - `groups`: `pecha_api/texts/groups/groups_views.py` - `segments`: `pecha_api/texts/segments/segments_views.py` -- `mappings`: `pecha_api/texts/mappings/mappings_views.py` - `topics`: `pecha_api/topics/topics_views.py` - `users`: `pecha_api/users/users_views.py` - `share`: `pecha_api/share/share_views.py` @@ -39,9 +38,6 @@ Routers included in `pecha_api/app.py`: - `recitations`: `pecha_api/recitations/recitations_view.py` - `user follows`: `pecha_api/user_follows/user_follow_views.py` - `user recitations`: `pecha_api/plans/users/recitation/user_recitations_views.py` -- `text uploader`: `pecha_api/text_uploader/text_uploader_views.py` -- `text metadata`: `pecha_api/text_uploader/text_metadata/text_metadata_views.py` -- `uploader collections`: `pecha_api/text_uploader/collections/uploader_collections_views.py` - `cataloger`: `pecha_api/cataloger/cataloger_views.py` ### Dependencies diff --git a/openpecha_api/segments/openpecha_segment_service.py b/openpecha_api/segments/openpecha_segment_service.py index 7ea540344..edea57bd4 100644 --- a/openpecha_api/segments/openpecha_segment_service.py +++ b/openpecha_api/segments/openpecha_segment_service.py @@ -36,3 +36,10 @@ async def fetch_segment_content(segment_id: str) -> Optional[str]: if isinstance(value, str): return value return None + +async def fetch_segment_details(segment_id:str) : + client=get_authenticated_open_pecha_client() + http_client=client.get_async_httpx_client() + response=await http_client.get(f"/v2/segments/{segment_id}") + response.raise_for_status() + return response.json() diff --git a/pecha_api/app.py b/pecha_api/app.py index 33ad99ae2..19cb940c1 100644 --- a/pecha_api/app.py +++ b/pecha_api/app.py @@ -6,14 +6,11 @@ from pecha_api.db.mongo_database import lifespan from pecha_api.auth import auth_views -from pecha_api.sheets import sheets_views from pecha_api.collections import collections_views from pecha_api.terms import terms_views from pecha_api.texts import texts_views from pecha_api.topics import topics_views from pecha_api.users import users_views -from pecha_api.texts.mappings import mappings_views -from pecha_api.texts.segments import segments_views from pecha_api.texts.groups import groups_views from pecha_api.share import share_views from pecha_api.search import search_views @@ -34,10 +31,7 @@ from pecha_api.recitations import recitations_view from pecha_api.user_follows import user_follow_views from pecha_api.plans.users.recitation import user_recitations_views -from pecha_api.text_uploader import text_uploader_views from pecha_api.cataloger import cataloger_views -from pecha_api.text_uploader.text_metadata import text_metadata_views -from pecha_api.text_uploader.collections import uploader_collections_views from pecha_api.collections import collections_openpecha_views from pecha_api.texts import texts_openpecha_views from pecha_api.texts.segments import segments_openpecha_views @@ -52,15 +46,12 @@ lifespan=lifespan ) api.include_router(auth_views.auth_router) -api.include_router(sheets_views.sheets_router) api.include_router(collections_views.collections_router) api.include_router(terms_views.terms_router) api.include_router(texts_views.text_router) api.include_router(groups_views.group_router) -api.include_router(segments_views.segment_router) api.include_router(topics_views.topics_router) api.include_router(users_views.user_router) -api.include_router(mappings_views.mapping_router) api.include_router(search_views.search_router) api.include_router(share_views.share_router) api.include_router(plan_auth_views.plan_auth_router) @@ -80,10 +71,7 @@ api.include_router(recitations_view.recitation_router) api.include_router(user_follow_views.user_follow_router) api.include_router(user_recitations_views.user_recitation_router) -api.include_router(text_uploader_views.text_uploader_router) api.include_router(cataloger_views.cataloger_router) -api.include_router(text_metadata_views.text_metadata_router) -api.include_router(uploader_collections_views.text_uploader_collections_router) api.include_router(routines_views.routines_router) api.include_router(collections_openpecha_views.collections_v2_router) api.include_router(texts_openpecha_views.texts_v2_router) diff --git a/pecha_api/cache/cache_enums.py b/pecha_api/cache/cache_enums.py index 152086bf2..860bbf51f 100644 --- a/pecha_api/cache/cache_enums.py +++ b/pecha_api/cache/cache_enums.py @@ -8,19 +8,10 @@ class CacheType(Enum): TEXT_TABLE_OF_CONTENTS = "text_table_of_contents" DETAIL_TEXT_TABLE_OF_CONTENT = "detail_text_table_of_content" - SEGMENT_DETAIL = "segment_detail" SEGMENTS_DETAILS = "segments_details" - SEGMENT_INFO = "segment_info" - SEGMENT_ROOT_TEXT = "segment_root_text" - SEGMENT_TRANSLATIONS = "segment_translations" - SEGMENT_COMMENTARIES = "segment_commentaries" GROUP_DETAIL = "group_detail" - SHEETS = "sheets" - SHEET_DETAIL = "sheet_detail" - SHEET_TABLE_OF_CONTENT = "sheet_table_of_content" - USER_INFO = "user_info" TOPICS = "topics" diff --git a/pecha_api/config.py b/pecha_api/config.py index 9f8206439..7490f7d2a 100644 --- a/pecha_api/config.py +++ b/pecha_api/config.py @@ -72,8 +72,6 @@ ### text uploader script configuration APPLICATION = "webuddhist", - ACCESS_TOKEN="", - COLLECTION_LANGUAGES = ["bo", "en", "zh"], #pecha api configuration EXTERNAL_PECHA_API_URL="", @@ -83,8 +81,6 @@ EXTERNAL_TITLE_SEARCH_API_URL="", - SQS_TIMEOUT=1800, - ) diff --git a/pecha_api/error_contants.py b/pecha_api/error_contants.py index 51335bb11..0700ce21b 100644 --- a/pecha_api/error_contants.py +++ b/pecha_api/error_contants.py @@ -3,9 +3,7 @@ class ErrorConstants: TEXT_NOT_FOUND_MESSAGE = "Text not found" TOKEN_ERROR_MESSAGE = "Invalid or no token found" TEXT_OR_TERM_NOT_FOUND_MESSAGE= "Text ID or Term ID is required" - SEGMENT_MAPPING_ERROR_MESSAGE="Segment mapping update Failed" SEGMENT_NOT_FOUND_MESSAGE = 'Segment not found' - SAME_TEXT_MAPPING_ERROR_MESSAGE = "Mapping within same text not allowed" TABLE_OF_CONTENT_NOT_FOUND_MESSAGE = "Table of content not found" CONTENT_ID_NOT_FOUND_MESSAGE="Content ID is required" GROUP_NOT_FOUND_MESSAGE="Group not found" diff --git a/pecha_api/recitations/recitations_services.py b/pecha_api/recitations/recitations_services.py index db862efb3..0ee422004 100644 --- a/pecha_api/recitations/recitations_services.py +++ b/pecha_api/recitations/recitations_services.py @@ -1,11 +1,9 @@ -from pecha_api.recitations.recitations_enum import LanguageCode,RecitationListTextType +from pecha_api.recitations.recitations_enum import RecitationListTextType from pecha_api.texts.texts_enums import TextType from typing import List, Dict, Union,Optional -from pecha_api.collections.collections_repository import get_all_collections_by_parent, get_collection_id_by_slug -from pecha_api.collections.collections_service import get_collection +from pecha_api.collections.collections_repository import get_collection_id_by_slug from pecha_api.recitations.recitations_repository import apply_search_recitation_title_filter, get_text_images_by_text_ids from pecha_api.recitations.recitations_response_models import RecitationDTO, RecitationsResponse -from pecha_api.texts.texts_repository import get_all_texts_by_collection from pecha_api.texts.texts_service import get_root_text_by_collection_id from fastapi import HTTPException from starlette import status @@ -15,7 +13,8 @@ from pecha_api.texts.texts_utils import TextUtils from pecha_api.texts.texts_response_models import TextDTO, TableOfContent from pecha_api.texts.texts_repository import get_contents_by_id, get_all_texts_by_group_id -from pecha_api.texts.segments.segments_service import get_segment_by_id, get_related_mapped_segments, get_segment_details_by_id, get_related_mapped_segments_batch, get_segments_details_by_ids +from pecha_api.texts.segments.segments_service import get_segments_details_by_ids +from pecha_api.texts.segments.segments_repository import get_related_mapped_segments_batch from pecha_api.texts.segments.segments_utils import SegmentUtils from pecha_api.texts.segments.segments_response_models import SegmentTranslation, SegmentTransliteration, SegmentAdaptation, SegmentRecitation from pecha_api.texts.segments.segments_response_models import SegmentDTO diff --git a/pecha_api/share/share_service.py b/pecha_api/share/share_service.py index 64c961e4c..bc66ead26 100644 --- a/pecha_api/share/share_service.py +++ b/pecha_api/share/share_service.py @@ -1,11 +1,10 @@ from fastapi import HTTPException import io from pecha_api.error_contants import ErrorConstants -from pecha_api.texts.segments.segments_utils import SegmentUtils from starlette.responses import StreamingResponse from pecha_api.texts.texts_utils import TextUtils from .pecha_text_image_generator import generate_segment_image -from pecha_api.texts.segments.segments_service import get_segment_details_by_id +from pecha_api.texts.segments.segments_openpecha_service import get_openpecha_segment_details_by_id from pecha_api.config import get import anyio @@ -65,16 +64,13 @@ async def _generate_segment_content_image_(share_request: ShareRequest): main_content_text = get("SITE_NAME") reference_text = get("SITE_NAME") language = share_request.language - # If segment_id is provided, get the segment details if share_request.segment_id is not None: - await SegmentUtils.validate_segment_exists(segment_id=share_request.segment_id) - segment = await get_segment_details_by_id(segment_id=share_request.segment_id) - main_content_text = segment.content - - text_id = segment.text_id - text_detail = await TextUtils.get_text_detail_by_id(text_id=text_id) - reference_text = text_detail.title - language = text_detail.language + segment_details = await get_openpecha_segment_details_by_id( + segment_id=share_request.segment_id, + ) + main_content_text = segment_details.content + reference_text = segment_details.text.title + language = segment_details.text.language elif share_request.text_id is not None: text_detail = await TextUtils.get_text_detail_by_id(text_id=share_request.text_id) main_content_text = text_detail.title diff --git a/pecha_api/sheets/__init__.py b/pecha_api/sheets/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pecha_api/sheets/sheets_enum.py b/pecha_api/sheets/sheets_enum.py deleted file mode 100644 index 0d527ad7e..000000000 --- a/pecha_api/sheets/sheets_enum.py +++ /dev/null @@ -1,9 +0,0 @@ -from enum import Enum - -class SortOrder(Enum): - ASC = "asc" - DESC = "desc" - -class SortBy(Enum): - CREATED_DATE = "created_date" - PUBLISHED_DATE = "published_date" diff --git a/pecha_api/sheets/sheets_models.py b/pecha_api/sheets/sheets_models.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pecha_api/sheets/sheets_repository.py b/pecha_api/sheets/sheets_repository.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pecha_api/sheets/sheets_response_models.py b/pecha_api/sheets/sheets_response_models.py deleted file mode 100644 index 1ec5a2e6b..000000000 --- a/pecha_api/sheets/sheets_response_models.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from pydantic import BaseModel - -from pecha_api.texts.segments.segments_models import SegmentType - -class Source(BaseModel): - position: int - type: SegmentType - content: str - -class CreateSheetRequest(BaseModel): - title: Optional[str] = "" - source: List[Source] - is_published: bool = False - -class SheetSegment(BaseModel): - segment_id: str - segment_number: int - content: Optional[str] = None - key: Optional[str] = None - text_title: Optional[str] = None - language: Optional[str] = None - type: SegmentType - -class SheetSection(BaseModel): - title: Optional[str] = None - section_number: int - parent_id: Optional[str] = None - segments: List[SheetSegment] - - -class Publisher(BaseModel): - name: str - username: str - email: str - avatar_url: Optional[str] = None - -class SheetDetailDTO(BaseModel): - id: str - sheet_title: str - created_date: str - publisher: Publisher - content: Optional[SheetSection] = None - views: int = 0 - is_published: bool - skip: int - limit: int - total: int - - -class SheetDTO(BaseModel): - id: str - title: str - summary: str - published_date: str - time_passed: str - views: int - is_published: bool - likes: Optional[List[str]] = [] - publisher: Publisher - language: Optional[str] = None - -class SheetDTOResponse(BaseModel): - sheets: List[SheetDTO] - skip: int - limit: int - total: int - -class SheetIdRequest(BaseModel): - id: str - -class SheetImageResponse(BaseModel): - url: str - key: str - -class SheetIdResponse(BaseModel): - sheet_id: str \ No newline at end of file diff --git a/pecha_api/sheets/sheets_service.py b/pecha_api/sheets/sheets_service.py deleted file mode 100644 index 77d852a75..000000000 --- a/pecha_api/sheets/sheets_service.py +++ /dev/null @@ -1,620 +0,0 @@ -import os -import uuid -import re -from typing import Optional, Dict, List -import hashlib -from fastapi import UploadFile, HTTPException, status - - -from pecha_api.texts.texts_models import Text -from pecha_api.error_contants import ErrorConstants -from pecha_api.config import get -from .sheets_response_models import CreateSheetRequest, SheetImageResponse, Publisher -from ..uploads.S3_utils import upload_bytes, generate_presigned_access_url -from pecha_api.image_utils import ImageUtils -from pecha_api.utils import Utils -from pecha_api.texts.texts_utils import TextUtils -from pecha_api.texts.texts_cache_service import update_text_details_cache - -from pecha_api.users.users_models import Users - -from .sheets_enum import ( - SortBy, - SortOrder -) - -from pecha_api.users.users_service import ( - validate_user_exists, - get_user_info -) -from pecha_api.users.user_response_models import UserInfoResponse - -from pecha_api.texts.groups.groups_response_models import ( - CreateGroupRequest, - GroupDTO -) -from pecha_api.texts.groups.groups_enums import GroupType -from pecha_api.texts.groups.groups_service import ( - create_new_group, - delete_group_by_group_id -) - -from pecha_api.texts.texts_response_models import ( - CreateTextRequest, - UpdateTextRequest -) -from pecha_api.texts.texts_service import ( - create_new_text, - create_table_of_content, - remove_table_of_content_by_text_id, - update_text_details, - delete_text_by_text_id, - get_sheet, - get_table_of_content_by_sheet_id -) - -from pecha_api.users.users_service import ( - validate_and_extract_user_details, - fetch_user_by_email -) -from pecha_api.texts.texts_enums import TextType -from pecha_api.texts.texts_response_models import ( - TextDTO, - TableOfContent, - TableOfContentType, - Section, - TextSegment -) -from pecha_api.texts.mappings.mappings_repository import get_sheet_first_content_by_ids - -from pecha_api.texts.segments.segments_models import SegmentType -from pecha_api.texts.segments.segments_response_models import ( - CreateSegment, - CreateSegmentRequest, - SegmentResponse, - SegmentDTO -) -from pecha_api.texts.segments.segments_service import ( - create_new_segment, - remove_segments_by_text_id, - get_segments_details_by_ids -) - -from pecha_api.sheets.sheets_response_models import ( - SheetIdResponse, - SheetDetailDTO, - Publisher, - SheetSection, - SheetSegment, - SheetDTOResponse, - SheetDTO -) -from pecha_api.texts.texts_cache_service import ( - delete_text_details_by_id_cache, - delete_table_of_content_by_sheet_id_cache -) -from pecha_api.texts.segments.segments_cache_service import ( - delete_segments_details_by_ids_cache -) - -from pecha_api.cache.cache_enums import CacheType -from pecha_api.cache.cache_repository import update_cache -from pecha_api.utils import Utils -import logging - -DEFAULT_SHEET_SECTION_NUMBER = 1 - -def _strip_html_tags_(html_content: str) -> str: - #Remove HTML tags from content and return clean text. - clean = re.compile('<.*?>') - return re.sub(clean, '', html_content).strip() - -async def _generate_sheet_summary_(sheet_id: str) -> str: - # Generate a summary from the first content segment limited to max_words. - try: - # Get sheet table of content - sheet_table_of_content: Optional[TableOfContent] = await get_table_of_content_by_sheet_id( - sheet_id=sheet_id - ) - - if not sheet_table_of_content or not sheet_table_of_content.sections: - return "" - - # Get all segment IDs from the table of content - segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_table_of_content.sections) - - if not segment_ids: - return "" - - content_segment = await _get_sheet_first_content_details_by_type_(segment_ids=segment_ids, segment_type=SegmentType.CONTENT) - - if content_segment: # not NONE - content = content_segment.content - return clean_text(content) - # Return empty string if no content segment found - return "" - - except Exception: - # Return empty string if any error occurs during summary generation - return "" - -def clean_text(content: str) -> str: - max_words = 30 - clean_text = _strip_html_tags_(content) - if clean_text: - # Split into words and limit to max_words - words = clean_text.split() - if len(words) <= max_words: - return " ".join(words) - else: - return " ".join(words[:max_words]) + "..." - else: - return "" - -async def fetch_sheets( - token: Optional[str] = None, - language: Optional[str] = None, - email: Optional[str] = None, - sort_by: Optional[SortBy] = None, - sort_order: Optional[SortOrder] = None, - skip: int = 0, - limit: int = 10 -) -> SheetDTOResponse: - - if email is None: - # Case 1: Community page - show all published sheets filtered by language - sheets = await get_sheet( - is_published=True, - sort_by=sort_by, - sort_order=sort_order, - skip=skip, - limit=limit - ) - else: - sheets = await _fetch_user_sheets_( - token = token, - email = email, - sort_by = sort_by, - sort_order = sort_order, - skip = skip, - limit = limit - ) - - total = await Text.get_published_sheets_count_from_db(email=email) - sheets: SheetDTOResponse = await _generate_sheet_dto_response_(sheets = sheets, total = total, skip = skip, limit = limit) - - return sheets - - -async def get_sheet_by_id(sheet_id: str, skip: int, limit: int) -> SheetDetailDTO: - sheet_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=sheet_id) - user_details: UserInfoResponse = fetch_user_by_email(email=sheet_details.published_by) - sheet_table_of_content: Optional[TableOfContent] = await get_table_of_content_by_sheet_id( - sheet_id=sheet_id - ) - if sheet_table_of_content is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE) - - sections = sheet_table_of_content.sections if sheet_table_of_content else [] - sheet_dto: SheetDetailDTO = await _generate_sheet_detail_dto_( - sheet_details=sheet_details, - user_details=user_details, - sheet_sections=sections, - skip=skip, - limit=limit - ) - return sheet_dto - -async def _generate_sheet_detail_dto_( - sheet_details: TextDTO, - user_details: UserInfoResponse, - sheet_sections: List[Section], - views: int, - skip: int, - limit: int -) -> SheetDetailDTO: - publisher = Publisher( - name=f"{user_details.firstname} {user_details.lastname}", - username=user_details.username, - email=user_details.email, - avatar_url=user_details.avatar_url - ) - segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections) - segments_dict: Dict[str, SegmentDTO] = await get_segments_details_by_ids(segment_ids=segment_ids) - - sheet_section: Optional[SheetSection] = None - if sheet_sections: - sheet_section = await _generate_sheet_section_( - segments=sheet_sections[0].segments, - segments_dict=segments_dict - ) - return SheetDetailDTO( - id=sheet_details.id, - sheet_title=sheet_details.title, - created_date=sheet_details.created_date, - publisher=publisher, - content=sheet_section, - is_published=sheet_details.is_published, - views=views, - skip=skip, - limit=limit, - total=len(sheet_sections), - ) - - -async def create_new_sheet(create_sheet_request: CreateSheetRequest, token: str) -> SheetIdResponse: - group_id = await _create_sheet_group_(token=token) - text_id = await _create_sheet_text_( - title=create_sheet_request.title, - token=token, - group_id=group_id - ) - sheet_segments: Dict[str, str] = await _process_and_upload_sheet_segments( - create_sheet_request=create_sheet_request, - text_id=text_id, - token=token - ) - await _generate_and_upload_sheet_table_of_content( - create_sheet_request=create_sheet_request, - text_id=text_id, - segment_dict=sheet_segments, - token=token - ) - return SheetIdResponse(sheet_id=text_id) - -async def update_sheet_by_id( - sheet_id: str, - update_sheet_request: CreateSheetRequest, - token: str - ) -> SheetIdResponse: - - is_valid_user = validate_user_exists(token=token) - if not is_valid_user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE) - - await delete_text_details_by_id_cache(text_id=sheet_id, cache_type=CacheType.TEXT_DETAIL) - sheet_table_of_content: Optional[TableOfContent] = await _delete_sheet_table_of_content_cache_(sheet_id=sheet_id) - - await _delete_sheet_segments_cache_(sheet_table_of_content=sheet_table_of_content) - - await remove_segments_by_text_id(text_id=sheet_id) - - await remove_table_of_content_by_text_id(text_id=sheet_id) - - await _update_text_details_(sheet_id=sheet_id, update_sheet_request=update_sheet_request) - - sheet_segments: Dict[str, str] = await _process_and_upload_sheet_segments( - create_sheet_request=update_sheet_request, - text_id=sheet_id, - token=token - ) - await _generate_and_upload_sheet_table_of_content( - create_sheet_request=update_sheet_request, - text_id=sheet_id, - segment_dict=sheet_segments, - token=token - ) - sheet_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=sheet_id) - - # Update cache with new sheet data after successful update - await update_text_details_cache(text_id=sheet_id, updated_text_data=sheet_details, cache_type=CacheType.SHEET_DETAIL) - - return SheetIdResponse(sheet_id=sheet_id) - -async def _delete_sheet_table_of_content_cache_(sheet_id: str) -> Optional[TableOfContent]: - sheet_table_of_content: Optional[TableOfContent] = await get_table_of_content_by_sheet_id(sheet_id=sheet_id) - if sheet_table_of_content is not None: - await delete_table_of_content_by_sheet_id_cache(sheet_id=sheet_id, cache_type=CacheType.SHEET_TABLE_OF_CONTENT) - return sheet_table_of_content - -async def _delete_sheet_segments_cache_(sheet_table_of_content: Optional[TableOfContent]): - sections = sheet_table_of_content.sections if sheet_table_of_content else [] - segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sections) - await delete_segments_details_by_ids_cache(segment_ids=segment_ids, cache_type=CacheType.SEGMENTS_DETAILS) - -async def delete_sheet_by_id(sheet_id: str, token: str): - is_valid_user = validate_user_exists(token=token) - if not is_valid_user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE) - - - sheet_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=sheet_id) - - user_details: UserInfoResponse = await get_user_info(token=token) - - if user_details.email != sheet_details.published_by: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ErrorConstants.FORBIDDEN_ERROR_MESSAGE - ) - - await delete_group_by_group_id(group_id=sheet_details.group_id) - await remove_segments_by_text_id(text_id=sheet_id) - await remove_table_of_content_by_text_id(text_id=sheet_id) - await delete_text_by_text_id(text_id=sheet_id) - - # delete_sheet_by_id_cache( - # sheet_id=sheet_id - # ) - -def upload_sheet_image_request(sheet_id: Optional[str], file: UploadFile) -> SheetImageResponse: - # Validate and compress the uploaded image - image_utils = ImageUtils() - compressed_image = image_utils.validate_and_compress_image(file=file, content_type=file.content_type) - file_name, ext = os.path.splitext(file.filename) - unique_id = str(uuid.uuid4()) - - # If no id is provided, use a random UUID as the folder name - path = "images/sheet_images" - image_path_full = f"{path}/{sheet_id}/{unique_id}" if sheet_id is not None else f"{path}/{unique_id}" - sheet_image_name = f"{image_path_full}/{file_name}{ext}" - upload_key = upload_bytes( - bucket_name=get("AWS_BUCKET_NAME"), - s3_key=sheet_image_name, - file=compressed_image, - content_type=file.content_type - ) - presigned_url = generate_presigned_access_url( - bucket_name=get("AWS_BUCKET_NAME"), - s3_key=upload_key - ) - - return SheetImageResponse(url=presigned_url, key=upload_key) - - -async def _generate_sheet_detail_dto_( - sheet_details: TextDTO, - user_details: UserInfoResponse, - sheet_sections: List[Section], - skip: int, - limit: int -) -> SheetDetailDTO: - publisher = Publisher( - name=f"{user_details.firstname} {user_details.lastname}", - username=user_details.username, - email=user_details.email, - avatar_url=user_details.avatar_url - ) - segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections) - segments_dict: Dict[str, SegmentDTO] = await get_segments_details_by_ids(segment_ids=segment_ids) - - sheet_section: Optional[SheetSection] = None - if sheet_sections: - sheet_section = await _generate_sheet_section_( - segments=sheet_sections[0].segments, - segments_dict=segments_dict - ) - - return SheetDetailDTO( - id=sheet_details.id, - sheet_title=sheet_details.title, - created_date=sheet_details.created_date, - is_published=sheet_details.is_published, - publisher=publisher, - content=sheet_section, - skip=skip, - limit=limit, - total=len(sheet_sections), - ) - -async def _fetch_user_sheets_(token: str, email: str, sort_by: SortBy, sort_order: SortOrder, skip: int, limit: int): - if token == "None": - _is_sheet_published_ = True - else: - current_user: Users = validate_and_extract_user_details(token=token) - _is_sheet_published_ = None if current_user.email == email else True - sheets = await get_sheet( - published_by=email, - is_published=_is_sheet_published_, - sort_by=sort_by, - sort_order=sort_order, - skip=skip, - limit=limit - ) - return sheets - -async def _generate_sheet_dto_response_(sheets, total, skip: int, limit: int) -> SheetDTOResponse: - sheets_dto = [ - SheetDTO( - id = str(sheet.id), - title = sheet.title, - summary = await _generate_sheet_summary_(str(sheet.id)), - published_date = sheet.published_date, - time_passed = Utils.time_passed(published_time=sheet.published_date, language=sheet.language), - views = sheet.views, - is_published = sheet.is_published, - likes = sheet.likes or [], - publisher = _create_publisher_object_(published_by=sheet.published_by), - language = sheet.language - ) - for sheet in sheets - ] - return SheetDTOResponse( - sheets = sheets_dto, - total = total, - skip = skip, - limit = limit - ) - -def _create_publisher_object_(published_by: str) -> Publisher: - - user_profile: UserInfoResponse = fetch_user_by_email(email=published_by) - - return Publisher( - name=f"{user_profile.firstname} {user_profile.lastname}".strip() or user_profile.username, - username=user_profile.username, - email=user_profile.email, - avatar_url=user_profile.avatar_url - ) - -async def _generate_sheet_section_(segments: List[TextSegment], segments_dict: Dict[str, SegmentDTO]) -> SheetSection: - sheet_segments = [] - for segment in segments: - segment_details: SegmentDTO = segments_dict[segment.segment_id] - if segment_details.type == SegmentType.SOURCE: - segment_text_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=segment_details.text_id) - sheet_segments.append( - SheetSegment( - segment_id=segment.segment_id, - segment_number=segment.segment_number, - content=segment_details.content, - language=segment_text_details.language, - text_title=segment_text_details.title, - type=segment_details.type - ) - ) - else: - s3_key = None - presigned_url = None - if segment_details.type == SegmentType.IMAGE: - s3_key = segment_details.content - presigned_url = generate_presigned_access_url( - bucket_name=get("AWS_BUCKET_NAME"), - s3_key=segment_details.content - ) - sheet_segments.append( - SheetSegment( - segment_id=segment.segment_id, - segment_number=segment.segment_number, - content=presigned_url if presigned_url else segment_details.content, - type=segment_details.type, - key=s3_key - ) - ) - - return SheetSection( - section_number=DEFAULT_SHEET_SECTION_NUMBER, - segments=sheet_segments - ) - -async def _get_sheet_first_content_details_by_type_(segment_ids: List[str], segment_type: SegmentType) -> Optional[str]: - - # Get detailed firt segment information of type segment_type - segments_detail = await get_sheet_first_content_by_ids(segment_ids=segment_ids, segment_type=segment_type) - return segments_detail - -def _get_all_segment_ids_in_table_of_content_(sheet_sections: Section) -> List[str]: - segment_ids = [] - for section in sheet_sections: - for segment in section.segments: - segment_ids.append(segment.segment_id) - return segment_ids - -async def _update_text_details_(sheet_id: str, update_sheet_request: CreateSheetRequest): - update_text_request = UpdateTextRequest( - title=update_sheet_request.title, - is_published=update_sheet_request.is_published - ) - await update_text_details(text_id=sheet_id, update_text_request=update_text_request) - - -async def _generate_and_upload_sheet_table_of_content( - create_sheet_request: CreateSheetRequest, - text_id: str, - segment_dict: Dict[str, str], - token: str -): - sheet_table_of_content = _generate_sheet_table_of_content_( - create_sheet_request=create_sheet_request, - text_id=text_id, - segment_dict=segment_dict - ) - await create_table_of_content( - table_of_content_request=sheet_table_of_content, - token=token - ) - return sheet_table_of_content - -async def _process_and_upload_sheet_segments( - create_sheet_request: CreateSheetRequest, - text_id: str, - token: str -) -> Dict[str, str]: - create_segment_request_payload: CreateSegmentRequest = _generate_segment_creation_request_payload_( - create_sheet_request=create_sheet_request, - text_id=text_id - ) - new_segments: SegmentResponse = await create_new_segment( - create_segment_request=create_segment_request_payload, - token=token - ) - segment_dict: Dict[str, str] = _generate_segment_dictionary_(new_segments=new_segments) - return segment_dict - -def _generate_sheet_table_of_content_(create_sheet_request: CreateSheetRequest, text_id: str, segment_dict: Dict[str, str]) -> TableOfContent: - section = Section( - id=str(uuid.uuid4()), - section_number=1, - segments=[], - created_date=Utils.get_utc_date_time(), - updated_date=Utils.get_utc_date_time() - ) - for source in create_sheet_request.source: - if source.type == SegmentType.SOURCE: - segment = TextSegment( - segment_number = source.position, - segment_id = source.content - ) - else: - content_hash_value = hashlib.sha256(source.content.encode()).hexdigest() - segment = TextSegment( - segment_number = source.position, - segment_id = segment_dict[content_hash_value] - ) - section.segments.append(segment) - - table_of_content = TableOfContent( - text_id=text_id, - type=TableOfContentType.SHEET, - sections=[section] - ) - - return table_of_content - - -def _generate_segment_dictionary_(new_segments: SegmentResponse) -> Dict[str, str]: - segment_dict = {} - for segment in new_segments.segments: - if segment.type != SegmentType.SOURCE: - content_hash_value = hashlib.sha256(segment.content.encode()).hexdigest() - segment_dict[content_hash_value] = segment.id - return segment_dict - -def _generate_segment_creation_request_payload_(create_sheet_request: CreateSheetRequest, text_id: str) -> CreateSegmentRequest: - create_segment_request = CreateSegmentRequest( - text_id=text_id, - segments=[] - ) - for source in create_sheet_request.source: - if source.type == SegmentType.SOURCE: - continue - create_segment_request.segments.append( - CreateSegment( - content=source.content, - type=source.type - ) - ) - return create_segment_request - -async def _create_sheet_text_(title: str, token: str, group_id: str) -> str: - user_details = validate_and_extract_user_details(token=token) - if title is None or title == "": - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorConstants.SHEET_TITLE_REQUIRED_MESSAGE) - create_text_request = CreateTextRequest( - title=title, - group_id=group_id, - language=None, - published_by=user_details.email, - type=TextType.SHEET - ) - new_text: TextDTO = await create_new_text(create_text_request=create_text_request, token=token) - return new_text.id - - -async def _create_sheet_group_(token: str) -> str: - create_group_request = CreateGroupRequest( - type=GroupType.SHEET - ) - new_group: GroupDTO = await create_new_group(create_group_request=create_group_request, token=token) - - return new_group.id diff --git a/pecha_api/sheets/sheets_views.py b/pecha_api/sheets/sheets_views.py deleted file mode 100644 index 1f3e34011..000000000 --- a/pecha_api/sheets/sheets_views.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations -from fastapi import APIRouter, Query -from fastapi.security import HTTPBearer -from starlette import status - -from typing import Optional -from fastapi import APIRouter, Depends, UploadFile, File -from fastapi.security import HTTPAuthorizationCredentials -from typing import Annotated - -from .sheets_enum import ( - SortBy, - SortOrder -) -from .sheets_service import ( - create_new_sheet, - upload_sheet_image_request, - fetch_sheets, - get_sheet_by_id, - update_sheet_by_id, - delete_sheet_by_id -) - -from pecha_api.sheets.sheets_response_models import SheetIdResponse - -from .sheets_response_models import ( - CreateSheetRequest, - SheetDetailDTO, - SheetDTOResponse -) - -oauth2_scheme = HTTPBearer() -sheets_router = APIRouter( - prefix="/sheets", - tags=["Sheets"] -) - -@sheets_router.get("", status_code=status.HTTP_200_OK) -async def get_sheets( - authentication_credential: Annotated[Optional[HTTPAuthorizationCredentials], Depends(HTTPBearer(auto_error=False))], - language: Optional[str] = Query(default=None), - email: Optional[str] = Query(default=None), - sort_by: Optional[SortBy] = Query(default=None), - sort_order: Optional[SortOrder] = Query(default=None), - skip: int = Query(default=0), - limit: int = Query(default=10) -) -> SheetDTOResponse: - return await fetch_sheets( - token=authentication_credential.credentials if authentication_credential else None, - language=language, - email=email, - sort_by=sort_by, - sort_order=sort_order, - skip=skip, - limit=limit - ) - -@sheets_router.get("/{sheet_id}", status_code=status.HTTP_200_OK) -async def get_sheet( - sheet_id: str, - skip: int = Query(default=0), - limit: int = Query(default=10) -) -> SheetDetailDTO: - return await get_sheet_by_id(sheet_id=sheet_id, skip=skip, limit=limit) - -@sheets_router.post("", status_code=status.HTTP_201_CREATED) -async def create_sheet( - create_sheet_request: CreateSheetRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], -) -> SheetIdResponse: - return await create_new_sheet( - create_sheet_request=create_sheet_request, - token=authentication_credential.credentials - ) - - -@sheets_router.put("/{sheet_id}", status_code=status.HTTP_200_OK) -async def update_sheet( - sheet_id: str, - update_sheet_request: CreateSheetRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], -) -> SheetIdResponse: - return await update_sheet_by_id( - sheet_id=sheet_id, - update_sheet_request=update_sheet_request, - token=authentication_credential.credentials - ) - -@sheets_router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_sheet( - sheet_id: str, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], -): - await delete_sheet_by_id( - sheet_id=sheet_id, - token=authentication_credential.credentials - ) - -@sheets_router.post("/upload", status_code=status.HTTP_201_CREATED) -def upload_sheet_image(sheet_id: Optional[str] = None, file: UploadFile = File(...)): - return upload_sheet_image_request(sheet_id=sheet_id, file=file) diff --git a/pecha_api/text_uploader/collections/collection_model.py b/pecha_api/text_uploader/collections/collection_model.py deleted file mode 100644 index 3e181c6c6..000000000 --- a/pecha_api/text_uploader/collections/collection_model.py +++ /dev/null @@ -1,12 +0,0 @@ -from pydantic import BaseModel -from typing import Optional, Dict - -class CollectionPayload(BaseModel): - pecha_collection_id: Optional[str] = None - slug: str - titles: Dict[str, str] - descriptions: Dict[str, str] - parent_id: Optional[str] - - - \ No newline at end of file diff --git a/pecha_api/text_uploader/collections/collection_service.py b/pecha_api/text_uploader/collections/collection_service.py deleted file mode 100644 index 48f49930d..000000000 --- a/pecha_api/text_uploader/collections/collection_service.py +++ /dev/null @@ -1,276 +0,0 @@ -from typing import Any -from pecha_api.text_uploader.collections.collections_repository import get_collections, post_collections -from pecha_api.text_uploader.constants import COLLECTION_LANGUAGES -from pecha_api.text_uploader.collections.collection_model import CollectionPayload -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - -from pecha_api.text_uploader.collections.collections_repository import get_collection_by_pecha_collection_id - -import logging - -class CollectionService: - - async def upload_collections(self, text_upload_request: TextUploadRequest, token: str): - - await self.build_recursive_multilingual_payloads( - destination_url=text_upload_request.destination_url, - openpecha_api_url=text_upload_request.openpecha_api_url, - access_token=token - ) - - - async def get_collections_service(self, openpecha_api_url: str, parent_id: str | None = None): - return await get_collections( - openpecha_api_url=openpecha_api_url, - languages=COLLECTION_LANGUAGES, - parent_id=parent_id - ) - - async def build_recursive_multilingual_payloads( - self, - destination_url: str, - openpecha_api_url: str, - access_token: str, - remote_parent_id: str | None = None, - local_parent_id: str | None = None - ) -> list[dict[str, Any]]: - - # Fetch collections for this level from the remote (OpenPecha) API. - # This `remote_parent_id` is **only** for traversing the source tree. - collections_by_language = await self.get_collections_service( - openpecha_api_url=openpecha_api_url, - parent_id=remote_parent_id - ) - - # Build the multilingual payload for the current level. This returns one - # combined payload per `pecha_collection_id`, where `titles` and - # `descriptions` contain entries for all languages. - multilingual_payloads = self.build_multilingual_payload( - collections_by_language - ) - - # First, upload collections at this level to the destination (webuddhist) - # backend, capturing the *destination* IDs so they can be used as - # `parent_id` for the next level. - for payload in multilingual_payloads: - # Determine parent_id: if local_parent_id is provided, use it. - # Otherwise, look up the parent in the CSV using the parent's pecha_collection_id - parent_id_to_use = local_parent_id - - # If we don't have a local_parent_id but this collection has a parent, - # try to find the parent's destination ID from the CSV log - if parent_id_to_use is None: - parent_pecha_id = payload.get("parent_id") - if parent_pecha_id: - # Normalize parent pecha_collection_id if it's in dict format - if isinstance(parent_pecha_id, dict) and "$oid" in parent_pecha_id: - parent_pecha_id = str(parent_pecha_id["$oid"]) - elif parent_pecha_id is not None: - parent_pecha_id = str(parent_pecha_id) - - # Look up parent's destination ID from CSV - # if parent_pecha_id: - # parent_id_to_use = get_parent_id_by_pecha_collection_id(parent_pecha_id) - # if payload.get("slug") is None: - # continue - collection_model = CollectionPayload( - pecha_collection_id=payload.get("pecha_collection_id"), - slug=payload.get("slug"), - titles=payload.get("titles"), - descriptions=payload.get("descriptions"), - # Use the resolved parent_id (from parameter or CSV lookup) - parent_id=parent_id_to_use, - ) - - - existing_collection_id = await get_collection_by_pecha_collection_id(pecha_collection_id=payload.get("pecha_collection_id"), destination_url=destination_url) - # Upload to webuddhist backend. We send the full multilingual - # payload body, and use "en" as the request language context. - response_data = {} - if not existing_collection_id: - response_data = await post_collections(destination_url=destination_url, language="en", collection_model=collection_model, access_token=access_token) - logging.info(f" '{payload.get('slug')!r}' uploaded successfully") - else: - logging.warning(f" '{payload.get('slug')!r}' ALREADY EXIST, skipping") - - # Extract the newly created destination collection ID so it can be - # used as the parent for this node's children. - - raw_local_id = ( - response_data.get("id") - or response_data.get("_id") - or response_data.get("collection_id") - ) - if isinstance(raw_local_id, dict) and "$oid" in raw_local_id: - payload["local_id"] = str(raw_local_id["$oid"]) - elif raw_local_id is not None: - payload["local_id"] = str(raw_local_id) - else: - payload["local_id"] = None - - - # For each payload that has children, fetch and attach them recursively. - for payload in multilingual_payloads: - has_sub_child = payload.get("has_sub_child") or payload.get("has_child") - - if not has_sub_child: - payload["children"] = [] - continue - - # Normalise the **remote/source** ID that should be used as - # `remote_parent_id` for the next level when talking to OpenPecha. - raw_remote_id = payload.get("pecha_collection_id") - next_remote_parent_id: str | None - if isinstance(raw_remote_id, dict) and "$oid" in raw_remote_id: - next_remote_parent_id = str(raw_remote_id["$oid"]) - elif raw_remote_id is not None: - next_remote_parent_id = str(raw_remote_id) - else: - next_remote_parent_id = None - - # If we don't have a usable remote parent id, we can't descend - # further in the source API. - if next_remote_parent_id is None: - payload["children"] = [] - continue - - # The *local* parent_id for children is the ID we just created in - # the destination backend for this node. - next_local_parent_id: str | None = payload.get("local_id") - - # Recurse asynchronously for the next level. - payload["children"] = await self.build_recursive_multilingual_payloads( - destination_url=destination_url, - openpecha_api_url=openpecha_api_url, - access_token=access_token, - remote_parent_id=next_remote_parent_id, - local_parent_id=next_local_parent_id, - ) - - return multilingual_payloads - - def build_multilingual_payload( - self, collections_by_language: list[dict[str, Any]] - ) -> list[dict[str, Any]]: - """ - Create a payload that combines collections with the same ID across - multiple languages into a **single multilingual document per ID**. - - Input (from `get_collections`): - - [ - { - "language": "en", - "collections": [ - { - "_id": {"$oid": "..."}, - "slug": "Liturgy", - "title": "Liturgy", - "description": "Prayers and rituals", - "parent_id": null, - "has_sub_child": true - }, - ... - ], - }, - { - "language": "bo", - "collections": [ - { - "_id": {"$oid": "..."}, - "slug": "Liturgy", - "title": "ཁ་འདོན།", - "description": "ཆོ་ག་དང་འདོན་ཆ།", - "parent_id": null, - "has_sub_child": true - }, - ... - ], - }, - ] - - Output (one combined payload per `pecha_collection_id`): - - { - "pecha_collection_id": "sfsfsf-sfsdf", - "slug": "Madhyamaka", - "titles": { - "en": "Madhyamaka", - "bo": "དབུ་མ།" - }, - "descriptions": { - "en": "Madhyamaka treatises", - "bo": "དབུ་མའི་གཞུང་སྣ་ཚོགས།" - }, - "parent_id": null, - "has_sub_child": true | false - } - """ - - combined: dict[str, dict[str, Any]] = {} - - for entry in collections_by_language: - language = entry["language"] - for collection in entry["collections"]: - # --- ID handling ------------------------------------------------- - # Prefer `id`, fall back to `_id` if necessary. - raw_id = collection.get("id", collection.get("_id")) - - # Normalise ID to a string key, and also store this as - # `pecha_collection_id` so that the final payload field is a - # simple string (as in the desired output example). - if isinstance(raw_id, dict) and "$oid" in raw_id: - id_key = str(raw_id["$oid"]) - else: - id_key = str(raw_id) - - # --- Parent / child flags --------------------------------------- - parent_value = collection.get("parent_id", collection.get("parent")) - has_sub_child_value = collection.get( - "has_sub_child", collection.get("has_child") - ) - - if id_key not in combined: - combined[id_key] = { - "pecha_collection_id": id_key, - "slug": collection.get("slug"), - "titles": {}, - # Always initialise description keys for all configured languages. - "descriptions": {lang: "" for lang in COLLECTION_LANGUAGES}, - "parent_id": parent_value, - "has_sub_child": has_sub_child_value, - } - - # --- Titles / descriptions -------------------------------------- - title_value = collection.get("title") - if title_value is None: - title_value = collection.get("titles", {}).get(language) - - description_value = collection.get("description") - if description_value is None: - description_value = collection.get("descriptions", {}).get( - language - ) - - if title_value is not None: - combined[id_key]["titles"][language] = title_value - - # Always use the English title as the slug so that all - # per-language payloads sharing this collection ID have a - # consistent, human-readable slug. - if language == "en": - combined[id_key]["slug"] = title_value - - if description_value is not None: - # Ensure the descriptions dictionary exists (it should from - # initialisation, but we guard just in case). - if "descriptions" not in combined[id_key]: - combined[id_key]["descriptions"] = {} - combined[id_key]["descriptions"][language] = description_value - - # Return the combined multilingual nodes directly – one payload per - # `pecha_collection_id`, with `titles` and `descriptions` merged across - # languages that share the same ID. - return list(combined.values()) - - diff --git a/pecha_api/text_uploader/collections/collections_repository.py b/pecha_api/text_uploader/collections/collections_repository.py deleted file mode 100644 index 91da64175..000000000 --- a/pecha_api/text_uploader/collections/collections_repository.py +++ /dev/null @@ -1,101 +0,0 @@ -from typing import Any -import asyncio - -import requests - -from typing import Optional - -from pecha_api.text_uploader.constants import APPLICATION, DestinationURL, ACCESS_TOKEN -from pecha_api.text_uploader.collections.collection_model import CollectionPayload -from pecha_api.collections.collections_repository import create_collection - - -async def get_collections( - openpecha_api_url: str, languages: list[str], parent_id: str | None = None -) -> list[dict[str, Any]]: - """ - Fetch the list of collections (categories) from the remote API for - a list of languages. - - The remote API expects `application` and `language` as query parameters, - e.g. `...?application=webuddhist&language=en`. - - Returns a list in the form: - - [ - { - "language": "en", - "collections": [ ... raw API items ... ], - }, - { - "language": "bo", - "collections": [ ... raw API items ... ], - }, - ] - """ - all_collections: list[dict[str, Any]] = [] - categories_url = f"{openpecha_api_url}/v2/categories/" - - for language in languages: - params = { - "application": APPLICATION, - "language": language, - "parent_id": parent_id, - } - - # `requests` is synchronous; run it in a thread so we can still await it. - response = await asyncio.to_thread( - requests.get, - categories_url, - params=params, - ) - response.raise_for_status() - - all_collections.append( - { - "language": language, - "collections": response.json(), - } - ) - - return all_collections - -async def get_collection_by_pecha_collection_id(pecha_collection_id: str, destination_url: str) -> Optional[str]: - text_metadata_url = f"{destination_url}/text-uploader/collections/{pecha_collection_id}" - response = await asyncio.to_thread(requests.get, text_metadata_url) - response.raise_for_status() - return response.json() - - -async def post_collections(destination_url: str, language: str, collection_model: CollectionPayload, access_token: str) -> dict[str, Any]: - url = f"{destination_url}/collections" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - params = {"language": language} - payload = collection_model.model_dump() - - # `requests` is synchronous; run it in a thread so we can still await it. - response = await asyncio.to_thread( - requests.post, - url, - headers=headers, - params=params, - json=payload, - ) - - if not response.ok: - print( - f"POST /collections failed " - f"(language={language}) status={response.status_code} " - f"body={response.text}" - ) - - return response.json() - - - -async def upload_collection(CollectionPayload) -> dict[str, Any]: - return await create_collection(create_collection_request=CollectionPayload) - diff --git a/pecha_api/text_uploader/collections/uploader_collections_views.py b/pecha_api/text_uploader/collections/uploader_collections_views.py deleted file mode 100644 index ad5920e7b..000000000 --- a/pecha_api/text_uploader/collections/uploader_collections_views.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi import APIRouter -from starlette import status -from typing import Optional - -from pecha_api.collections.collections_service import get_collection_by_pecha_collection_id_service -text_uploader_collections_router = APIRouter( - prefix="/text-uploader/collections", - tags=["Text Uploader"] -) - -@text_uploader_collections_router.get("/{pecha_collection_id}", status_code=status.HTTP_200_OK) -async def get_collection_by_pecha_collection_id(pecha_collection_id: str) -> Optional[str]: - return await get_collection_by_pecha_collection_id_service( - pecha_collection_id=pecha_collection_id - ) \ No newline at end of file diff --git a/pecha_api/text_uploader/constants.py b/pecha_api/text_uploader/constants.py deleted file mode 100644 index 877267221..000000000 --- a/pecha_api/text_uploader/constants.py +++ /dev/null @@ -1,35 +0,0 @@ -from enum import Enum - - - -APPLICATION = "webuddhist" - -ACCESS_TOKEN = "1234567890" - -COLLECTION_LANGUAGES = ["bo", "en", "zh"] - -class TextType(Enum): - COMMENTARY = "commentary" - ROOT = "root" - TRANSLATION = "translation" - TRANSLATION_SOURCE = "translation_source" - NONE = None - -VERSION_TEXT_TYPE = [TextType.TRANSLATION.value, TextType.ROOT.value, TextType.TRANSLATION_SOURCE.value, TextType.NONE.value, TextType.ROOT.value] - - -class OpenPechaAPIURL(Enum): - DEVELOPMENT = "https://api-l25bgmwqoa-uc.a.run.app" - PRODUCTION = "https://api-aq25662yyq-uc.a.run.app" - STAGING = "https://api-kwgjscy6gq-uc.a.run.app" - -class SQSURL(Enum): - DEVELOPMENT = "https://sqs-uploader-service.onrender.com" - - -class DestinationURL(Enum): - PRODUCTION = "https://api.webuddhist.com/api/v1" - STAGING = "https://webuddhist-tst-backend.onrender.com/api/v1" - DEVELOPMENT = "https://webuddhist-dev-backend.onrender.com/api/v1" - LOCAL = "http://localhost:8000/api/v1" - diff --git a/pecha_api/text_uploader/mapping/mapping_model.py b/pecha_api/text_uploader/mapping/mapping_model.py deleted file mode 100644 index 6b6225356..000000000 --- a/pecha_api/text_uploader/mapping/mapping_model.py +++ /dev/null @@ -1,8 +0,0 @@ -from pydantic import BaseModel -from typing import List - -class TriggerMappingPayload(BaseModel): - text_ids: List[str] - source: str - destination: str - diff --git a/pecha_api/text_uploader/mapping/mapping_repository.py b/pecha_api/text_uploader/mapping/mapping_repository.py deleted file mode 100644 index ea2a6b241..000000000 --- a/pecha_api/text_uploader/mapping/mapping_repository.py +++ /dev/null @@ -1,26 +0,0 @@ -from pecha_api.text_uploader.constants import SQSURL -from pecha_api.config import get_int -from pecha_api.text_uploader.mapping.mapping_model import TriggerMappingPayload -import httpx -from fastapi import HTTPException - -async def trigger_mapping_repo(text_ids: list[str], source: str, destination: str): - - url = f"{SQSURL.DEVELOPMENT.value}/job/text-ids" - headers = { - "Content-Type": "application/json", - } - timeout = get_int("SQS_TIMEOUT") - - payload = TriggerMappingPayload(text_ids=text_ids, source=source, destination=destination).model_dump() - print("payload>>>>>>>>>>>>>>>>", payload) - async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.post( - url, - json=payload, - headers=headers - ) - - if not response.is_success: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() \ No newline at end of file diff --git a/pecha_api/text_uploader/mapping/mapping_services.py b/pecha_api/text_uploader/mapping/mapping_services.py deleted file mode 100644 index de62d2406..000000000 --- a/pecha_api/text_uploader/mapping/mapping_services.py +++ /dev/null @@ -1,11 +0,0 @@ -from pecha_api.text_uploader.mapping.mapping_repository import trigger_mapping_repo -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest -class MappingService: - def __init__(self): - self.all_text = {} - - async def trigger_mapping(self, text_ids: dict[str, str], text_upload_request: TextUploadRequest): - text_ids_list = list(text_ids.values()) - source = text_upload_request.openpecha_api_url - destination = text_upload_request.destination_url - await trigger_mapping_repo(text_ids_list, source, destination) \ No newline at end of file diff --git a/pecha_api/text_uploader/pipeline.py b/pecha_api/text_uploader/pipeline.py deleted file mode 100644 index 0d959a56e..000000000 --- a/pecha_api/text_uploader/pipeline.py +++ /dev/null @@ -1,70 +0,0 @@ -import asyncio -from fastapi import HTTPException -from starlette import status -from pecha_api.error_contants import ErrorConstants - - -from pecha_api.text_uploader.collections.collection_service import CollectionService -from pecha_api.text_uploader.text_metadata.text_metadata_service import TextMetadataService -from pecha_api.text_uploader.segments.segment_service import SegmentService -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest -from pecha_api.text_uploader.table_of_content.toc_service import TocService -from pecha_api.text_uploader.mapping.mapping_services import MappingService -from pecha_api.users.users_service import verify_admin_access -from pecha_api.text_uploader.constants import DestinationURL, OpenPechaAPIURL - -from pecha_api.text_uploader.text_uploader_response_model import TextUploadResponse - - -async def pipeline(text_upload_request: TextUploadRequest, token: str)-> TextUploadResponse: - - destination_url_enum = text_upload_request.destination_url - openpecha_api_url_enum = text_upload_request.openpecha_api_url - destination_url = DestinationURL[destination_url_enum].value - openpecha_api_url = OpenPechaAPIURL[openpecha_api_url_enum].value - - text_upload_request_payload = TextUploadRequest( - destination_url=destination_url, - openpecha_api_url=openpecha_api_url, - text_id=text_upload_request.text_id, - ) - - is_admin = verify_admin_access(token=token) - if not is_admin: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE) - - # # collection upload - collection = CollectionService() - await collection.upload_collections(text_upload_request=text_upload_request_payload, token=token) - - # text metadata upload - text_metadata = TextMetadataService() - instance_ids_response = await text_metadata.upload_text_metadata_service(text_upload_request=text_upload_request_payload, token=token) - - new_texts = instance_ids_response.new_text - all_text = instance_ids_response.all_text - - new_uploaded_texts_count = len(new_texts) - - if new_uploaded_texts_count > 0: - # segment upload - segment = SegmentService() - await segment.upload_segments(text_upload_request=text_upload_request_payload,text_ids = new_texts, token=token) - - # table of content upload - toc = TocService() - await toc.upload_toc(text_ids=new_texts, text_upload_request=text_upload_request_payload, token=token) - - - - #mapping upload - if text_upload_request.destination_url != DestinationURL.LOCAL.name: - mapping = MappingService() - await mapping.trigger_mapping(text_ids=all_text, text_upload_request=text_upload_request) - - - if new_uploaded_texts_count > 0: - - return TextUploadResponse(message=new_texts) - else: - return TextUploadResponse(message="All texts are already uploaded") \ No newline at end of file diff --git a/pecha_api/text_uploader/segments/segment_model.py b/pecha_api/text_uploader/segments/segment_model.py deleted file mode 100644 index aaec55ac7..000000000 --- a/pecha_api/text_uploader/segments/segment_model.py +++ /dev/null @@ -1,15 +0,0 @@ -from pydantic import BaseModel -from typing import Any, Optional - -class Segment(BaseModel): - content: str - type: str - -class SegmentModel(BaseModel): - text_id: str - segments: list[Segment] - -class ManifestationModel(BaseModel): - job_id: Optional[str] = None - status: Optional[str] = None - message: Optional[str] = None \ No newline at end of file diff --git a/pecha_api/text_uploader/segments/segment_respository.py b/pecha_api/text_uploader/segments/segment_respository.py deleted file mode 100644 index c9d86aed1..000000000 --- a/pecha_api/text_uploader/segments/segment_respository.py +++ /dev/null @@ -1,135 +0,0 @@ -from typing import Any, List - -import asyncio -import requests - - -async def get_segments_annotation(pecha_text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]: - """ - Fetch the list of segment annotations for a given Pecha text (instance). - """ - instances_url = f"{openpecha_api_url}/v2/instances/{pecha_text_id}" - params = { - "annotation": "true", - "content": "true" - } - - # `requests` is synchronous; run it in a thread so callers can still await. - response = await asyncio.to_thread( - requests.get, - instances_url, - params=params, - ) - response.raise_for_status() - - # The API is expected to return JSON, usually a list of instances/segments. - return response.json() - -async def get_segments_id_by_annotation_id(annotation_id: str, openpecha_api_url: str) -> list[dict[str, Any]]: - url = f"{openpecha_api_url}/v2/annotations/{annotation_id}" - response = await asyncio.to_thread(requests.get, url) - response.raise_for_status() - return response.json() - -async def get_segments_by_id(annotation_id: str, openpecha_api_url: str) -> dict[str, Any]: - """ - Fetch a single segmentation annotation by its ID. - """ - url = ( - f"{openpecha_api_url}/v2/annotations/{annotation_id}" - ) - - response = await asyncio.to_thread( - requests.get, - url, - ) - response.raise_for_status() - - return response.json() - - -async def get_segment_content( - segment_id: List[str], pecha_text_id: str, openpecha_api_url: str -) -> list[dict[str, Any]]: - - url = ( - f"{openpecha_api_url}" - f"/v2/instances/{pecha_text_id}/segment-content" - ) - payload = { - "segment_ids": segment_id - } - - try: - response = await asyncio.to_thread( - requests.post, - url, - json=payload, - timeout=100000, - ) - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as e: - print(f"HTTP Error in get_segment_content: {e}") - print(f"Status Code: {response.status_code}") - print(f"Response: {response.text}") - raise - except requests.exceptions.RequestException as e: - print(f"Request Error in get_segment_content: {e}") - raise - except Exception as e: - print(f"Unexpected Error in get_segment_content: {e}") - raise - - -async def post_segments( - segments_payload: dict[str, Any], - destination_url: str, - token: str, -) -> dict[str, Any]: - - url = f"{destination_url}/segments" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - # payload: dict[str, Any] = { - # "text_id": segments_payload["text_id"], - # "segments": segments_payload["segments"], - # } - - response = await asyncio.to_thread( - requests.post, - url, - headers=headers, - json=segments_payload, - ) - - if not response.ok: - print( - "POST /segments failed" - f"(text_id={segments_payload['text_id']}) status={response.status_code} " - f"body={response.text}" - ) - response.raise_for_status() - - return response.json() - - - -async def get_segments_by_id(annotation_id: str, openpecha_api_url: str) -> dict[str, Any]: - """ - Fetch a single segmentation annotation by its ID. - """ - url = ( - f"{openpecha_api_url}/v2/annotations/{annotation_id}" - ) - - response = await asyncio.to_thread( - requests.get, - url, - ) - response.raise_for_status() - - return response.json() - diff --git a/pecha_api/text_uploader/segments/segment_service.py b/pecha_api/text_uploader/segments/segment_service.py deleted file mode 100644 index f1c92d58b..000000000 --- a/pecha_api/text_uploader/segments/segment_service.py +++ /dev/null @@ -1,103 +0,0 @@ -import logging -from typing import Any, List -from pecha_api.text_uploader.segments.segment_respository import ( - get_segments_annotation, - post_segments, - get_segments_id_by_annotation_id, - get_segments_by_id -) - -from pecha_api.texts.segments.segments_repository import get_segments_by_text_id -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - -logging.basicConfig(level=logging.INFO) - -class SegmentService: - - async def upload_segments(self, text_upload_request: TextUploadRequest, text_ids: dict[str, Any], token: str): - - try: - for text_id, pecha_text_id in text_ids.items(): - - if await self.is_text_segments_uploaded(text_id): - print(f"Segments for text_id {pecha_text_id} already uploaded. Skipping...") - continue - - instance = await self.get_segments_annotation_by_pecha_text_id(text_upload_request, pecha_text_id) - annotation_ids = self.get_annotation_ids(instance) - annotation_sengments = await get_segments_id_by_annotation_id(annotation_ids[0], text_upload_request.openpecha_api_url) - segments_ids = [segment["id"] for segment in annotation_sengments["data"]] - # Process segment_ids in batches to manage batch size - - # segments_contents = await self.make_batch_segments_content(segments_ids, pecha_text_id) - segments_contents = self.parse_segments_content(annotation_sengments["data"], instance['content']) - - await self.upload_bulk_segments(text_id, segments_contents,text_upload_request, token) - except Exception as e: - print("Error: ", e) - - - def parse_segments_content(self, segments_annotation: List[dict[str, Any]], base_text: str) -> List[dict[str, Any]]: - segments_content = [] - for segment in segments_annotation: - segment_content = base_text[segment["span"]["start"]:segment["span"]["end"]] - segments_content.append({ - "segment_id": segment["id"], - "content": segment_content - }) - return segments_content - - - async def upload_bulk_segments(self, text_id: str, segments_content: List[dict[str, Any]], text_upload_request, token: str) -> List[dict[str, Any]]: - """ - Create and post segments in batches to avoid payload size limits. - """ - batch_size = 400 # Adjust batch size as needed - total_segments = len(segments_content) - - print(f"\nPosting {total_segments} segments in batches of {batch_size}...\n") - - for i in range(0, total_segments, batch_size): - batch = segments_content[i:i + batch_size] - batch_number = (i // batch_size) + 1 - total_batches = (total_segments + batch_size - 1) // batch_size - - payload = { - "text_id": text_id, - "segments": [ - { - "pecha_segment_id": segment["segment_id"], - "content": segment["content"], - "type": "source", - } for segment in batch - ] - } - - logging.info(f"Posting batch {batch_number}/{total_batches} ({len(batch)} segments)...\n") - await post_segments(payload, text_upload_request.destination_url, token) - - async def get_segments_annotation_by_pecha_text_id( - self, text_upload_request: TextUploadRequest, pecha_text_id: str - ) -> dict[str, Any]: - return await get_segments_annotation(pecha_text_id, text_upload_request.openpecha_api_url) - - - def get_annotation_ids(self, instance: dict[str, Any]) -> list[str]: - annotations = instance["annotations"] or [] - segmentation_ids: list[str] = [] - for annotation in annotations: - if annotation["type"] == "segmentation": - segmentation_ids.append(annotation["annotation_id"]) - return segmentation_ids - - - async def is_text_segments_uploaded(self, text_id: str) -> bool: - segments = await get_segments_by_text_id(text_id) - return len(segments) > 0 - - - async def get_segments_by_id_list(self, annotation_ids: str, text_upload_request: TextUploadRequest) -> List[dict[str, Any]]: - - segments = await get_segments_by_id(annotation_ids, text_upload_request.openpecha_api_url) - return segments - diff --git a/pecha_api/text_uploader/table_of_content/toc_repository.py b/pecha_api/text_uploader/table_of_content/toc_repository.py deleted file mode 100644 index 5dad43460..000000000 --- a/pecha_api/text_uploader/table_of_content/toc_repository.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Any -import asyncio -import requests - -async def post_toc(toc_payload: dict[str, Any], destination_url: str, token: str): - url = f"{destination_url}/texts/table-of-content" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - response = await asyncio.to_thread(requests.post, url, headers=headers, json=toc_payload) - response.raise_for_status() - return response.json() \ No newline at end of file diff --git a/pecha_api/text_uploader/table_of_content/toc_service.py b/pecha_api/text_uploader/table_of_content/toc_service.py deleted file mode 100644 index 248b44282..000000000 --- a/pecha_api/text_uploader/table_of_content/toc_service.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Any -import uuid -import logging - -from pecha_api.text_uploader.segments.segment_service import SegmentService -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - -from pecha_api.text_uploader.table_of_content.toc_repository import post_toc - - -logging.basicConfig(level=logging.INFO) - -class TocService: - def __init__(self): - self.segment_service = SegmentService() - - async def upload_toc(self, text_ids: dict[str, Any], text_upload_request: TextUploadRequest, token: str): - - for text_id, pecha_text_id in text_ids.items(): - # Check if TOC already uploaded for this pecha_text_id - - instance = await self.segment_service.get_segments_annotation_by_pecha_text_id(text_upload_request, pecha_text_id) - annotation_ids = self.segment_service.get_annotation_ids(instance) - annotation_segments = await self.segment_service.get_segments_by_id_list(annotation_ids[0], text_upload_request) - ordered_segments = await self.order_segments_by_annotation_span(annotation_segments) - toc_payload = self.create_toc_payload(ordered_segments, text_id) - - - await post_toc(toc_payload, text_upload_request.destination_url, token) - logging.info(f'Table of Content uploaded successfully for text_id: {text_id}') - - - async def order_segments_by_annotation_span(self, annotation_segments: dict[str, Any]): - - segments_data = annotation_segments.get("data", []) - sorted_segments = sorted(segments_data, key=lambda x: x["span"]["start"]) - result = [ - {"segment_id": segment["id"], "segment_number": idx + 1} - for idx, segment in enumerate(sorted_segments) - ] - return result - - def create_toc_payload(self, ordered_segments: list[dict[str, Any]], text_id: str): - section_id = str(uuid.uuid4()) - payload = { - "text_id": text_id, - "type": "text", - "sections": [ - { - "id": section_id, - "title": "1", - "section_number": 1, - "segments": ordered_segments - } - ] - } - return payload diff --git a/pecha_api/text_uploader/text_metadata/text_group_repository.py b/pecha_api/text_uploader/text_metadata/text_group_repository.py deleted file mode 100644 index 1e5174f98..000000000 --- a/pecha_api/text_uploader/text_metadata/text_group_repository.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Any, List - -import asyncio -import requests - -from pecha_api.text_uploader.constants import OpenPechaAPIURL, DestinationURL, ACCESS_TOKEN -from pecha_api.text_uploader.text_metadata.text_metadata_model import TextGroupPayload -from pecha_api.text_uploader.text_metadata.text_metadata_model import CriticalInstanceResponse - - -CONTENT_TYPE = "application/json" - -async def get_texts(openpecha_api_url: str, type: str | None = None, limit: int | None = None, offset: int | None = None) -> list[dict[str, Any]]: - texts_url = f"{openpecha_api_url}/v2/texts" - - # `requests` is synchronous; run it in a thread so callers can still await. - params = { - "type": type, - "limit": 100, - "offset": 800, - } - response = await asyncio.to_thread(requests.get, texts_url, params=params) - response.raise_for_status() - - return response.json() - - -async def get_texts_by_category(category_id: str, openpecha_api_url: str) -> list[dict[str, Any]]: - texts_url = f"{openpecha_api_url}/v2/categories/{category_id}/texts" - params = { - "instance_type": 'critical', - "limit": 100, - "offset": 0, - } - response = await asyncio.to_thread(requests.get, texts_url, params=params) - response.raise_for_status() - return response.json() - - -async def get_related_texts(text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]: - related_texts_url = f"{openpecha_api_url}/v2/instances/{text_id}/related" - response = await asyncio.to_thread(requests.get, related_texts_url) - response.raise_for_status() - return response.json() - -async def get_text_instances(text_id: str, type: str, openpecha_api_url: str) -> list[dict[str, Any]]: - instances_url = f"{openpecha_api_url}/v2/texts/{text_id}/instances" - params = { - "type": type, - } - response = await asyncio.to_thread(requests.get, instances_url, params=params) - response.raise_for_status() - return response.json() - - -async def post_group(type: str, destination_url: str, token: str) -> dict[str, Any]: - """ - Create a text group in the destination (webuddhist) backend. - """ - url = f"{destination_url}/groups" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": CONTENT_TYPE, - } - payload = { - "type": type, - } - - response = await asyncio.to_thread( - requests.post, - url, - headers=headers, - json=payload, - ) - - if not response.ok: - # Print full details so we can see why the backend returned 4xx/5xx. - print( - f"POST /groups failed " - f"(status={response.status_code}) " - f"body={response.text}" - ) - response.raise_for_status() - - return response.json() - -async def get_critical_instances(text_id: str, openpecha_api_url: str) -> CriticalInstanceResponse: - critical_instances_url = f"{openpecha_api_url}/v2/texts/{text_id}/instances" - params = {"instance_type": "critical"} - - response = await asyncio.to_thread( - requests.get, - critical_instances_url, - params=params, - ) - response.raise_for_status() - critical_instances_list = response.json() - return CriticalInstanceResponse(critical_instances=critical_instances_list) - - -async def post_text(text_payload: TextGroupPayload, token: str, destination_url: str) -> dict[str, Any]: - - url = f"{destination_url}/texts" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": CONTENT_TYPE, - } - payload = text_payload.model_dump() - - response = await asyncio.to_thread( - requests.post, - url, - headers=headers, - json=payload, - ) - - if not response.ok: - print( - f"POST /texts failed " - f"(status={response.status_code}) " - f"body={response.text}" - ) - response.raise_for_status() - - return response.json() - - -async def get_text_related_by_work(text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]: - related_texts_url = f"{openpecha_api_url}/v2/texts/{text_id}/related-by-work" - response = await asyncio.to_thread(requests.get, related_texts_url) - response.raise_for_status() - data = response.json() - return data - -async def get_text_metadata(text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]: - text_metadata_url = f"{openpecha_api_url}/v2/texts/{text_id}" - response = await asyncio.to_thread(requests.get, text_metadata_url) - response.raise_for_status() - return response.json() - - -async def get_texts_by_pecha_text_ids(pecha_text_ids: List[str], destination_url: str) -> list[dict[str, Any]]: - url = f"{destination_url}/text-uploader/list" - headers = { - "Content-Type": CONTENT_TYPE, - } - instance_ids = list(pecha_text_ids) - payload = { - "pecha_text_ids": instance_ids - } - response = await asyncio.to_thread(requests.post, url, headers=headers, json=payload) - response.raise_for_status() - return response.json() \ No newline at end of file diff --git a/pecha_api/text_uploader/text_metadata/text_metadata_model.py b/pecha_api/text_uploader/text_metadata/text_metadata_model.py deleted file mode 100644 index 76c26d9d5..000000000 --- a/pecha_api/text_uploader/text_metadata/text_metadata_model.py +++ /dev/null @@ -1,37 +0,0 @@ -from pydantic import BaseModel -from typing import Optional, List, Dict,Any - - -class TextGroupPayload(BaseModel): - - pecha_text_id: Optional[str] = None - title: str | None = None - language: Optional[str] = None - isPublished: bool = False - group_id: Optional[str] = None - published_by: Optional[str] = None - type: str - categories: Optional[List[str]] = None - views: Optional[int] = 0 - source_link: Optional[str] = None - ranking: Optional[int] = None - license: Optional[str] = None - -class CriticalInstance(BaseModel): - id: str - bdrc: Optional[str] = None - wiki: Optional[str] = None - type: str - source: str - colophon: Optional[str] = None - incipit_title: Optional[Any] = None - alt_incipit_titles: Optional[List[Any]] = None - biblography_annotation: Optional[str] = None - -class CriticalInstanceResponse(BaseModel): - critical_instances: List[CriticalInstance] - -class TextInstanceIds(BaseModel): - new_text: Dict[str, str] - all_text: Dict[str, str] - diff --git a/pecha_api/text_uploader/text_metadata/text_metadata_service.py b/pecha_api/text_uploader/text_metadata/text_metadata_service.py deleted file mode 100644 index 1fc983b31..000000000 --- a/pecha_api/text_uploader/text_metadata/text_metadata_service.py +++ /dev/null @@ -1,170 +0,0 @@ -from typing import Any, List -import logging - -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest -from pecha_api.text_uploader.text_metadata.text_metadata_model import CriticalInstanceResponse, TextInstanceIds -from pecha_api.text_uploader.text_metadata.text_group_repository import ( - post_group, - get_critical_instances, - post_text, - get_text_related_by_work, - get_text_metadata -) -from pecha_api.text_uploader.collections.collections_repository import get_collection_by_pecha_collection_id -from pecha_api.text_uploader.text_metadata.text_metadata_model import TextGroupPayload -from pecha_api.text_uploader.text_metadata.text_group_repository import get_texts_by_pecha_text_ids - - -logging.basicConfig(level=logging.INFO) - -class TextMetadataService: - def __init__(self): - self.version_group_id: str | None = None - self.commentary_group_id: str | None = None - self.text_ids: list[str] = [] - self.category: dict[str, str] = {} - self.all_instance_ids: dict[str, str] = {} - - - async def upload_text_metadata_service(self, text_upload_request: TextUploadRequest, token: str): - text_id = text_upload_request.text_id - text = await get_text_metadata(text_id, text_upload_request.openpecha_api_url) - related_text_ids = [] - commentary_text_ids = [] - work_translation_group = {} - - text_related_by_work_response = await get_text_related_by_work(text_id, text_upload_request.openpecha_api_url) - for key in text_related_by_work_response.keys(): - if text_related_by_work_response[key]["relation"] not in ['commentary', 'sibling_commentary']: - work_translation_group[key] = text_related_by_work_response[key]["expression_ids"] - expression_ids = text_related_by_work_response[key]["expression_ids"] - related_text_ids.extend(expression_ids) - else: - commentary_ids = text_related_by_work_response[key]["expression_ids"] - commentary_text_ids.extend(commentary_ids) - if text["type"] not in ['commentary', 'sibling_commentary']: - related_text_ids.append(text_id) - else: - commentary_text_ids.append(text_id) - new_texts = await self.get_text_meta_data_service(text_ids=related_text_ids, type="translation", text_upload_request=text_upload_request, token=token) - new_commentary_texts = await self.get_text_meta_data_service(text_ids=commentary_text_ids, type="commentary", text_upload_request=text_upload_request, token=token) - new_texts.update(new_commentary_texts) - - result = TextInstanceIds(new_text=new_texts, all_text=self.all_instance_ids) - - # Reset - self.version_group_id = None - self.commentary_group_id = None - self.all_instance_ids = {} - return result - - async def get_text_meta_data_service(self, text_ids: List[str], type: str,text_upload_request: TextUploadRequest, token: str): - - if len(text_ids) == 0: - return {} - - uploaded_texts, uploaded_text_ids, instances = await self.get_uploaded_texts(text_ids, text_upload_request.openpecha_api_url, text_upload_request.destination_url) - - if len(uploaded_texts) > 0 and type == "translation": - group_id = uploaded_texts[0]['group_id'] - self.version_group_id = group_id - - new_texts = {} - for text_id in text_ids: - text_metadata = await get_text_metadata(text_id, text_upload_request.openpecha_api_url) - language = text_metadata['language'] - title = text_metadata['title'][language] - - if text_id in uploaded_text_ids: - logging.info(f"Skipping already uploaded text: {title}") - continue - - if type == "translation": - if not self.version_group_id: - group_response = await post_group('text', text_upload_request.destination_url, token) - self.version_group_id = group_response["id"] - logging.info(f"Created new group {group_response['id']} for translation") - - payload = await self.create_textmetada_payload(text_id, text_metadata, type="version", text_upload_request=text_upload_request) - - elif type == "commentary": - group_response = await post_group('commentary', text_upload_request.destination_url, token) - self.commentary_group_id = group_response["id"] - logging.info(f"Created new group {group_response['id']} for commentary") - - payload = await self.create_textmetada_payload(text_id, text_metadata, type="commentary", text_upload_request=text_upload_request) - - text_response = await post_text(payload, token, text_upload_request.destination_url) - response_text_id = text_response["id"] - new_texts[response_text_id] = instances[text_id] - - logging.info(f"Created new text {text_response['title']}") - - # get all instance ids - self.all_instance_ids.update(instances) - - return new_texts - - - - - - async def create_textmetada_payload(self, text_id: str, text_metadata: dict[str, Any], type: str, text_upload_request: TextUploadRequest): - language = text_metadata['language'] - title = text_metadata['title'][language] - instance = await self.get_text_critical_instance(text_id, text_upload_request.openpecha_api_url) - - if type == "version": - category_id = text_metadata.get("category_id", "") - wb_category_id = await self.get_wb_collection_id(pecha_collection_id=category_id, destination_url=text_upload_request.destination_url) - category_ids = [wb_category_id] - group_id = self.version_group_id - elif type == "commentary": - category_ids = [self.version_group_id] - group_id = self.commentary_group_id - - return TextGroupPayload( - pecha_text_id=instance.critical_instances[0].id, - title=title, - language=language, - isPublished=text_metadata.get("isPublished", True), - group_id=group_id, - published_by="", - type=type, - categories=category_ids, - views=text_metadata.get("views", 0), - source_link=instance.critical_instances[0].source, - ranking=text_metadata.get("ranking"), - license=text_metadata.get("license"), - ) - - - - async def get_text_critical_instance(self, text_id: str, openpecha_api_url: str) -> CriticalInstanceResponse: - critical_instances_response = await get_critical_instances(text_id, openpecha_api_url) - critical_instances_list = critical_instances_response.critical_instances - return CriticalInstanceResponse(critical_instances=critical_instances_list) - - async def get_uploaded_texts(self, text_ids: List[str], openpecha_api_url: str, destination_url: str): - instances = {} - expressions = {} - for text_id in text_ids: - text_critical_instance = await self.get_text_critical_instance(text_id, openpecha_api_url) - instance_id = text_critical_instance.critical_instances[0].id - instances[text_id] = instance_id - expressions[instance_id] = text_id - - instance_ids = instances.values() - texts = await get_texts_by_pecha_text_ids(pecha_text_ids=instance_ids, destination_url=destination_url) - pecha_text_ids = [text['pecha_text_id'] for text in texts] - uploaded_text_ids = [expressions[id] for id in instance_ids if id in pecha_text_ids] - - return texts, uploaded_text_ids, instances - - async def get_wb_collection_id(self, pecha_collection_id: str, destination_url: str) -> str: - wb_collection_id = await get_collection_by_pecha_collection_id(pecha_collection_id=pecha_collection_id, destination_url=destination_url) - if wb_collection_id is None: - raise ValueError(f"Collection with pecha_collection_id {pecha_collection_id} not found") - return wb_collection_id - - diff --git a/pecha_api/text_uploader/text_metadata/text_metadata_views.py b/pecha_api/text_uploader/text_metadata/text_metadata_views.py deleted file mode 100644 index 47d61a9d7..000000000 --- a/pecha_api/text_uploader/text_metadata/text_metadata_views.py +++ /dev/null @@ -1,16 +0,0 @@ -from fastapi import APIRouter -from starlette import status -from typing import Optional, List - -from pecha_api.texts.texts_response_models import TextDTO -from pecha_api.texts.texts_response_models import TextsByPechaTextIdsRequest -from pecha_api.texts.texts_service import get_text_by_pecha_text_ids_service - -text_metadata_router = APIRouter( - prefix="/text-uploader", - tags=["Text Uploader"] -) - -@text_metadata_router.post("/list", status_code=status.HTTP_200_OK) -async def get_text_by_pecha_text_ids(texts_by_pecha_text_ids_request: TextsByPechaTextIdsRequest) -> Optional[List[TextDTO]]: - return await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=texts_by_pecha_text_ids_request) \ No newline at end of file diff --git a/pecha_api/text_uploader/text_uploader_response_model.py b/pecha_api/text_uploader/text_uploader_response_model.py deleted file mode 100644 index f9b6120af..000000000 --- a/pecha_api/text_uploader/text_uploader_response_model.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic import BaseModel - - -class TextUploadRequest(BaseModel): - destination_url: str - openpecha_api_url: str - text_id: str - - -class TextUploadResponse(BaseModel): - message: dict[str, str] | str diff --git a/pecha_api/text_uploader/text_uploader_views.py b/pecha_api/text_uploader/text_uploader_views.py deleted file mode 100644 index 06eea2975..000000000 --- a/pecha_api/text_uploader/text_uploader_views.py +++ /dev/null @@ -1,21 +0,0 @@ -from fastapi import APIRouter, Depends -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from typing import Annotated - -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest -from pecha_api.text_uploader.pipeline import pipeline - -oauth2_scheme = HTTPBearer() - -text_uploader_router = APIRouter( - prefix="/text-uploader", - tags=["Text Uploader"] -) - -@text_uploader_router.post("") -async def upload_text( - text_upload_request: TextUploadRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)] - ): - response = await pipeline(text_upload_request=text_upload_request, token=authentication_credential.credentials) - return response \ No newline at end of file diff --git a/pecha_api/texts/mappings/__init__.py b/pecha_api/texts/mappings/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pecha_api/texts/mappings/mappings_repository.py b/pecha_api/texts/mappings/mappings_repository.py deleted file mode 100644 index 2215f6498..000000000 --- a/pecha_api/texts/mappings/mappings_repository.py +++ /dev/null @@ -1,30 +0,0 @@ -import uuid -from typing import List, Optional -from ..segments.segments_models import Mapping, Segment -from ..segments.segments_enum import SegmentType - -async def update_mapping(segment_id: uuid.UUID, text_id: str, mappings: List[Mapping]) -> Optional[Segment]: - result = await Segment.get_segment_by_id_and_text_id(segment_id=segment_id, text_id=text_id) - if result: - result.mapping = mappings - await result.save() - return result - return None - -async def get_segments_by_ids(segment_ids: List[str]) -> List[Segment]: - return await Segment.get_segments_by_ids(segment_ids=segment_ids) - -async def update_mappings(segments: List[Segment]) -> List[Segment]: - # Update all segments in bulk - for segment in segments: - await segment.save() - return segments - -async def get_sheet_first_content_by_ids(segment_ids: List[str], segment_type: SegmentType) -> Optional[Segment]: - - # NEW: Efficient - filters at database level - segment = await Segment.get_first_segment_by_ids_and_type( - segment_ids=segment_ids, - segment_type=segment_type - ) - return segment diff --git a/pecha_api/texts/mappings/mappings_response_models.py b/pecha_api/texts/mappings/mappings_response_models.py deleted file mode 100644 index 6064528bb..000000000 --- a/pecha_api/texts/mappings/mappings_response_models.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import List -from pydantic import BaseModel - - -class MappingsModel(BaseModel): - parent_text_id: str - segments: List[str] - - -class TextMapping(BaseModel): - text_id: str - segment_id: str - mappings: List[MappingsModel] - - -class TextMappingRequest(BaseModel): - text_mappings: List[TextMapping] diff --git a/pecha_api/texts/mappings/mappings_service.py b/pecha_api/texts/mappings/mappings_service.py deleted file mode 100644 index ec64b148c..000000000 --- a/pecha_api/texts/mappings/mappings_service.py +++ /dev/null @@ -1,272 +0,0 @@ -import asyncio -from typing import List, Dict, Tuple -from fastapi import HTTPException -from starlette import status - -from pecha_api.error_contants import ErrorConstants -from pecha_api.texts.segments.segments_repository import get_segments_by_pecha_segment_ids -from pecha_api.texts.texts_repository import get_texts_by_pecha_text_ids -from .mappings_repository import ( - update_mappings, - get_segments_by_ids -) -from .mappings_response_models import ( - TextMappingRequest, - MappingsModel, - TextMapping -) -from ..segments.segments_models import Segment, Mapping -from ..segments.segments_response_models import ( - SegmentResponse, - SegmentDTO, - MappingResponse -) -from ..segments.segments_utils import SegmentUtils -from ..texts_utils import TextUtils -from ...users.users_service import verify_admin_access - - -# Mappings Service -# =============== - -async def update_segment_mapping(text_mapping_request: TextMappingRequest, token: str) -> SegmentResponse: - # Verify admin access - is_admin: bool = verify_admin_access(token=token) - if not is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ErrorConstants.ADMIN_ERROR_MESSAGE - ) - text_id_dict, segment_id_dict = await _get_text_and_segment_ids(text_mapping_request=text_mapping_request) - for tm in text_mapping_request.text_mappings: - tm.text_id = text_id_dict[tm.text_id] - tm.segment_id = segment_id_dict[tm.segment_id] - for map in tm.mappings: - map.parent_text_id = text_id_dict[map.parent_text_id] - map.segments = [segment_id_dict[segment] for segment in map.segments] - - # Validate mapping request - await _validate_mapping_request(text_mapping_request=text_mapping_request) - - # Process segments - segment_dict: Dict[str, List[Mapping]] = _get_segments_from_text_mapping( - text_mappings=text_mapping_request.text_mappings - ) - segment_ids: List[str] = list(segment_dict.keys()) - - # Fetch and validate segments - segments: List[Segment] = await get_segments_by_ids(segment_ids=segment_ids) - if not segments: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No valid segments found to update" - ) - - # Update segments - segments_to_update: List[Segment] = await _construct_update_segments( - segments=segments, - update_segment_dict=segment_dict - ) - updated_segments: List[Segment] = await update_mappings(segments=segments_to_update) - if updated_segments: - # Convert all segments to SegmentDTO - segment_dtos = [ - SegmentDTO( - id=str(segment.id), - pecha_segment_id=segment.pecha_segment_id, - text_id=segment.text_id, - content=segment.content, - type=segment.type, - mapping=[MappingResponse(**mapping.model_dump()) for mapping in segment.mapping] - ) for segment in updated_segments - ] - return SegmentResponse(segments=segment_dtos) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE - ) - -async def delete_segment_mapping(text_mapping_request: TextMappingRequest,token: str): - # Verify admin access - is_admin: bool = verify_admin_access(token=token) - if not is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ErrorConstants.ADMIN_ERROR_MESSAGE - ) - # Validate mapping request - #await _validate_mapping_request(text_mapping_request=text_mapping_request) - # Process segments - segment_dict: Dict[str, List[Mapping]] = _get_segments_from_text_mapping( - text_mappings=text_mapping_request.text_mappings - ) - segment_ids: List[str] = list(segment_dict.keys()) - - # Fetch and validate segments - segments: List[Segment] = await get_segments_by_ids(segment_ids=segment_ids) - if not segments: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No valid segments found to update" - ) - # Update segments - segments_to_delete: List[Segment] = await _construct_delete_segments( - segments=segments, - delete_segment_dict=segment_dict - ) - deleted_segments: List[Segment] = await update_mappings(segments=segments_to_delete) - if deleted_segments: - # Convert all segments to SegmentDTO - segment_dtos = [ - SegmentDTO( - id=str(segment.id), - text_id=segment.text_id, - content=segment.content, - type=segment.type, - mapping=[MappingResponse(**mapping.model_dump()) for mapping in segment.mapping] - ) for segment in deleted_segments - ] - return SegmentResponse(segments=segment_dtos) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE - ) - -async def _get_text_and_segment_ids(text_mapping_request: TextMappingRequest) -> Tuple[Dict[str, str], Dict[str, str]]: - segment_ids=[] - text_ids=[] - for tm in text_mapping_request.text_mappings: - text_ids.append(tm.text_id) - segment_ids.append(tm.segment_id) - for map in tm.mappings: - text_ids.append(map.parent_text_id) - for segment in map.segments: - segment_ids.append(segment) - texts = await get_texts_by_pecha_text_ids(pecha_text_ids=text_ids) - segments = await get_segments_by_pecha_segment_ids(pecha_segment_ids=segment_ids) - text_id_dict = {text.pecha_text_id: str(text.id) for text in texts} - segment_id_dict = {segment.pecha_segment_id: str(segment.id) for segment in segments} - return text_id_dict, segment_id_dict - -async def _construct_delete_segments(segments: List[Segment], delete_segment_dict: Dict[str, List[Mapping]]) -> List[ - Segment]: - deleted_segments = [] - - for segment in segments: - segment_id = str(segment.id) - if segment_id in delete_segment_dict: - # Get mappings to delete - mappings_to_delete = delete_segment_dict[segment_id] - existing_mappings: Dict[str, Mapping] = _get_existing_mappings(segment) - # Filter out mappings that should be deleted - remaining_mappings = [ - mapping for mapping in existing_mappings.values() - if (mapping.text_id, tuple(sorted(mapping.segments))) not in { - (delete_map.text_id, tuple(sorted(delete_map.segments))) - for delete_map in mappings_to_delete - } - ] - # Update segment with remaining mappings - segment.mapping = remaining_mappings - deleted_segments.append(segment) - - return deleted_segments - -async def _validate_mapping_request(text_mapping_request: TextMappingRequest) -> bool: - tasks = [ - asyncio.create_task(_validate_request_info( - text_id=tm.text_id, - segment_id=tm.segment_id, - mappings=tm.mappings - )) for tm in text_mapping_request.text_mappings - ] - try: - for completed in asyncio.as_completed(tasks): - result = await completed - if not result: - # Cancel all other running tasks - for task in tasks: - if not task.done(): - task.cancel() - return False - except asyncio.CancelledError: - pass - - return True - -def _merge_segment_mappings(existing_mapping: Mapping, new_mapping: Mapping) -> Mapping: - # Create a set of unique segments and update existing mapping - unique_segments = set(existing_mapping.segments + new_mapping.segments) - existing_mapping.segments = list(unique_segments) - return existing_mapping - -def _get_existing_mappings(segment: Segment) -> Dict[str, Mapping]: - # Create a dictionary of existing mappings by text_id for easy lookup - return {mapping.text_id: mapping for mapping in (segment.mapping or [])} - -def _process_new_mappings(new_mappings: List[Mapping], existing_mappings: Dict[str, Mapping]) -> List[Mapping]: - merged = [] - processed_text_ids = set() - - # Process new mappings - for new_mapping in new_mappings: - processed_text_ids.add(new_mapping.text_id) - if new_mapping.text_id in existing_mappings: - merged.append(_merge_segment_mappings( - existing_mappings[new_mapping.text_id], - new_mapping - )) - else: - merged.append(new_mapping) - - # Add unprocessed existing mappings - merged.extend( - mapping for text_id, mapping in existing_mappings.items() - if text_id not in processed_text_ids - ) - - return merged - -async def _construct_update_segments(segments: List[Segment], update_segment_dict: Dict[str, List[Mapping]]) -> List[Segment]: - updated_segments = [] - - for segment in segments: - segment_id = str(segment.id) - if segment_id in update_segment_dict: - existing_mappings = _get_existing_mappings(segment) - new_mappings = update_segment_dict[segment_id] - - segment.mapping = _process_new_mappings(new_mappings, existing_mappings) - updated_segments.append(segment) - - return updated_segments - -def _get_segments_from_text_mapping(text_mappings: List[TextMapping]) -> Dict[str, List[Mapping]]: - segment_dict = {} - for text_mapping in text_mappings: - # Convert MappingsModel to Mapping objects - mappings = [ - Mapping(text_id=m.parent_text_id, segments=m.segments) - for m in text_mapping.mappings - ] - # Ensure segment_id is stored as string - segment_dict[str(text_mapping.segment_id)] = mappings - return segment_dict - -async def _validate_request_info(text_id: str, segment_id: str, mappings: List[MappingsModel]) -> bool: - # validate the text id - await TextUtils.validate_text_exists(text_id=text_id) - # validate the segment id - await SegmentUtils.validate_segment_exists(segment_id=segment_id) - # validate the parent ids - parent_text_ids = [mapping.parent_text_id for mapping in mappings] - if text_id in parent_text_ids: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorConstants.SAME_TEXT_MAPPING_ERROR_MESSAGE) - await TextUtils.validate_texts_exist(text_ids=parent_text_ids) - # validate segment ids - segment_ids = [segment for mapping in mappings for segment in mapping.segments] - await SegmentUtils.validate_segments_exists(segment_ids=segment_ids) - return True - diff --git a/pecha_api/texts/mappings/mappings_views.py b/pecha_api/texts/mappings/mappings_views.py deleted file mode 100644 index b12d28271..000000000 --- a/pecha_api/texts/mappings/mappings_views.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from starlette import status - -from .mappings_response_models import TextMappingRequest -from .mappings_service import update_segment_mapping, delete_segment_mapping -from ..segments.segments_response_models import SegmentResponse - -oauth2_scheme = HTTPBearer() - -mapping_router = APIRouter( - prefix="/mappings", - tags=["Text Mapping"] -) - - -@mapping_router.post("",status_code=status.HTTP_201_CREATED) -async def create_text_mapping(authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], - text_mapping_request: TextMappingRequest) -> SegmentResponse: - return await update_segment_mapping(token=authentication_credential.credentials, text_mapping_request=text_mapping_request) - -@mapping_router.delete("",status_code=status.HTTP_204_NO_CONTENT) -async def delete_text_mapping(authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], - text_mapping_request: TextMappingRequest) -> None: - await delete_segment_mapping(token=authentication_credential.credentials, text_mapping_request=text_mapping_request) \ No newline at end of file diff --git a/pecha_api/texts/segments/segments_cache_service.py b/pecha_api/texts/segments/segments_cache_service.py index 6bdf0438c..62c5d1475 100644 --- a/pecha_api/texts/segments/segments_cache_service.py +++ b/pecha_api/texts/segments/segments_cache_service.py @@ -2,91 +2,14 @@ from pecha_api.cache.cache_repository import ( get_cache_data, set_cache, - clear_cache ) from pecha_api import config from .segments_response_models import ( SegmentDTO, - SegmentInfoResponse, - SegmentRootMappingResponse, - SegmentTranslationsResponse, - SegmentCommentariesResponse ) from typing import Dict, List from pecha_api.cache.cache_enums import CacheType -# SEGMENTS -async def get_segment_details_by_id_cache(segment_id: str = None, text_details: bool = None) -> SegmentDTO: - payload = [segment_id, text_details] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: SegmentDTO = await get_cache_data(hash_key=hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = SegmentDTO(**cache_data) - return cache_data - -async def set_segment_details_by_id_cache(segment_id: str = None, text_details: bool = None, cache_type: CacheType = None, data: SegmentDTO = None): - payload = [segment_id, text_details, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def get_segment_info_by_id_cache(segment_id: str = None, cache_type: CacheType = None) -> SegmentInfoResponse: - payload = [segment_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: SegmentInfoResponse = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = SegmentInfoResponse(**cache_data) - return cache_data - -async def set_segment_info_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentInfoResponse = None): - payload = [segment_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def get_segment_root_mapping_by_id_cache(segment_id: str = None) -> SegmentRootMappingResponse: - payload = [segment_id] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: SegmentRootMappingResponse = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = SegmentRootMappingResponse(**cache_data) - return cache_data - -async def set_segment_root_mapping_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentRootMappingResponse = None): - payload = [segment_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def get_segment_translations_by_id_cache(segment_id: str = None) -> SegmentTranslationsResponse: - payload = [segment_id] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: SegmentTranslationsResponse = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = SegmentTranslationsResponse(**cache_data) - return cache_data - -async def set_segment_translations_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentTranslationsResponse = None): - payload = [segment_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def get_segment_commentaries_by_id_cache(segment_id: str = None) -> SegmentCommentariesResponse: - payload = [segment_id] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: SegmentCommentariesResponse = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = SegmentCommentariesResponse(**cache_data) - return cache_data - -async def set_segment_commentaries_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentCommentariesResponse = None): - payload = [segment_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - - async def get_segments_details_by_ids_cache(segment_ids: List[str] = None, cache_type: CacheType = None) -> Dict[str, SegmentDTO]: payload = list(segment_ids) + [cache_type] hashed_key: str = Utils.generate_hash_key(payload = payload) @@ -99,9 +22,4 @@ async def set_segments_details_by_ids_cache(segment_ids: List[str] = None, cache payload = list(segment_ids) + [cache_type] hashed_key: str = Utils.generate_hash_key(payload = payload) cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def delete_segments_details_by_ids_cache(segment_ids: List[str] = None, cache_type: CacheType = None): - payload = list(segment_ids) + [cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - await clear_cache(hash_key = hashed_key) \ No newline at end of file + await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) \ No newline at end of file diff --git a/pecha_api/texts/segments/segments_models.py b/pecha_api/texts/segments/segments_models.py index 7271dbba9..25f933936 100644 --- a/pecha_api/texts/segments/segments_models.py +++ b/pecha_api/texts/segments/segments_models.py @@ -24,30 +24,13 @@ class Settings: "mapping.segments" # Index for faster lookup of segment IDs within mapping arrays ] - @classmethod - async def get_segments_by_pecha_segment_ids(cls, pecha_segment_ids: List[str]) -> List["Segment"]: - pecha_segment_ids = [str(pecha_segment_id) for pecha_segment_id in pecha_segment_ids] - return await cls.find({cls.pecha_segment_id: {"$in": pecha_segment_ids}}).to_list() - @classmethod async def get_segment_by_id(cls, segment_id: str) -> Optional["Segment"]: return await cls.find_one(cls.id == uuid.UUID(segment_id)) - @classmethod - async def update_segment_by_pecha_segment_id(cls, pecha_segment_id: str, content: str) -> Optional["Segment"]: - return await cls.find_one(cls.pecha_segment_id == pecha_segment_id).update({"$set": {"content": content}}) - @classmethod async def get_segment_by_pecha_segment_id(cls, pecha_segment_id: str) -> Optional["Segment"]: return await cls.find_one(cls.pecha_segment_id == pecha_segment_id) - @classmethod - async def get_segments_by_ids(cls, segment_ids: List[str]) -> List["Segment"]: - segment_ids = [uuid.UUID(segment_id) for segment_id in segment_ids] - return await cls.find({"_id": {"$in": segment_ids}}).to_list() - - @classmethod - async def get_segment_by_id_and_text_id(cls, segment_id: uuid.UUID, text_id: str) -> Optional["Segment"]: - return await cls.find_one(cls.id == segment_id, cls.text_id == text_id) @classmethod async def get_segments_by_text_id(cls, text_id: str) -> List["Segment"]: @@ -74,13 +57,6 @@ async def exists_all(cls, segment_ids: List[uuid.UUID], batch_size: int = 100) - async def get_segments_by_ids(cls, segment_ids: List[str]) -> List["Segment"]: segment_ids = [uuid.UUID(segment_id) for segment_id in segment_ids] return await cls.find({"_id": {"$in": segment_ids}}).to_list(length=len(segment_ids)) - @classmethod - async def get_first_segment_by_ids_and_type(cls, segment_ids: List[str], segment_type: SegmentType) -> Optional["Segment"]: - """Get the first segment matching the IDs and type - optimized for single result""" - if not segment_ids: - return None - segment_uuid_ids = [uuid.UUID(segment_id) for segment_id in segment_ids] - return await cls.find_one({"_id": {"$in": segment_uuid_ids}, "type": segment_type}) @classmethod async def get_related_mapped_segments(cls, parent_segment_id: str) -> List["Segment"]: @@ -109,10 +85,6 @@ async def get_segments_by_pecha_ids( return await cls.find(query).to_list() - @classmethod - async def delete_segment_by_text_id(cls, text_id: str): - return await cls.find(cls.text_id == text_id).delete() - @classmethod async def get_related_mapped_segments_batch( cls, diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py index 680d481e5..06b398c22 100644 --- a/pecha_api/texts/segments/segments_openpecha_service.py +++ b/pecha_api/texts/segments/segments_openpecha_service.py @@ -8,6 +8,7 @@ from openpecha_api.segments.openpecha_segment_service import ( fetch_related_segments, fetch_segment_content, + fetch_segment_details, ) from openpecha_api.text.openpecha_text_service import fetch_text_by_id @@ -15,6 +16,9 @@ ParentSegment, V2RelatedSegmentItem, V2SegmentCommentariesResponse, + V2SegmentResponse, + V2SegmentRootTextResponse, + V2SegmentTextDetail, V2SegmentTextGroup, V2SegmentTranslationsResponse, ) @@ -24,6 +28,7 @@ TRANSLATION = "translation" COMMENTARY = "commentary" +RELATED_SEGMENTS_MAX_PAGE_SIZE = 100 def _classify_text(text_payload: Dict[str, Any]) -> Optional[str]: @@ -140,6 +145,121 @@ async def _get_related_segments_grouped_by_type( return parent_segment, [grouped[text_id] for text_id in group_order], has_more +async def _fetch_matching_related_segments_by_text_id( + segment_id: str, + text_id: str, + skip: int, + limit: int, +) -> Tuple[List[Dict[str, Any]], bool]: + matching_items: List[Dict[str, Any]] = [] + upstream_offset = 0 + upstream_has_more = True + target_count = skip + limit + 1 + + while upstream_has_more and len(matching_items) < target_count: + related_page = await fetch_related_segments( + segment_id=segment_id, + limit=RELATED_SEGMENTS_MAX_PAGE_SIZE, + offset=upstream_offset, + ) + items: List[Dict[str, Any]] = related_page.get("items", []) or [] + matching_items.extend( + item for item in items if item.get("text_id") == text_id + ) + upstream_has_more = bool(related_page.get("has_more", False)) + if not items: + break + upstream_offset += len(items) + + paginated_items = matching_items[skip : skip + limit] + has_more = len(matching_items) > skip + limit + return paginated_items, has_more + + +async def get_root_text_by_segment_id_from_openpecha( + text_id: str, + segment_id: str, + skip: int = 0, + limit: int = 10, +) -> V2SegmentRootTextResponse: + try: + parent_segment, (filtered_items, has_more) = await asyncio.gather( + _fetch_parent_segment(segment_id), + _fetch_matching_related_segments_by_text_id( + segment_id=segment_id, + text_id=text_id, + skip=skip, + limit=limit, + ), + ) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to fetch related segments from upstream service", + ) + + if not filtered_items: + return V2SegmentRootTextResponse( + parent_segment=parent_segment, + root_text=[], + skip=skip, + limit=limit, + has_more=has_more, + ) + + text_payload, *segment_contents = await asyncio.gather( + _fetch_text_safe(text_id), + *[_fetch_segment_content_safe(item["id"]) for item in filtered_items], + ) + + root_text_group = V2SegmentTextGroup( + text_id=text_id, + title=_extract_title(text_payload.get("title", {})) if text_payload else "", + language=text_payload.get("language") if text_payload else None, + segments=[ + V2RelatedSegmentItem(id=item["id"], content=content) + for item, content in zip(filtered_items, segment_contents) + ], + ) + + return V2SegmentRootTextResponse( + parent_segment=parent_segment, + root_text=[root_text_group], + skip=skip, + limit=limit, + has_more=has_more, + ) + +async def get_openpecha_segment_details_by_id( + segment_id: str, +) -> V2SegmentResponse: + content = await _fetch_segment_content_safe(segment_id) + if content is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Segment with id '{segment_id}' not found", + ) + + segment_details=await fetch_segment_details(segment_id) + text_payload = await _fetch_text_safe(segment_details.get("text_id")) + if text_payload: + text_detail = V2SegmentTextDetail( + text_id=segment_details.get("text_id"), + title=_extract_title(text_payload.get("title", {})), + language=text_payload.get("language"), + ) + else: + text_detail = None + + return V2SegmentResponse( + segment_id=segment_id, + content=content, + text=text_detail, + ) + + async def get_translations_by_segment_id_from_openpecha( segment_id: str, skip: int = 0, diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py index f364dc3f0..1ed0b58cf 100644 --- a/pecha_api/texts/segments/segments_openpecha_views.py +++ b/pecha_api/texts/segments/segments_openpecha_views.py @@ -1,27 +1,56 @@ -from typing import Annotated +from typing import Annotated, Optional from fastapi import APIRouter, Query from starlette import status from .segments_openpecha_service import ( get_commentaries_by_segment_id_from_openpecha, + get_openpecha_segment_details_by_id, + get_root_text_by_segment_id_from_openpecha, get_translations_by_segment_id_from_openpecha, ) from .segments_response_models import ( V2SegmentCommentariesResponse, + V2SegmentResponse, V2SegmentTranslationsResponse, + V2SegmentRootTextResponse, ) segments_v2_router = APIRouter( - prefix="/v2/segments", - tags=["segments-v2"], + prefix="/segments", + tags=["Segments"], ) +@segments_v2_router.get("/{segment_id}", status_code=status.HTTP_200_OK) +async def get_segment_v2( + segment_id: str, +) -> V2SegmentResponse: + return await get_openpecha_segment_details_by_id( + segment_id=segment_id, + ) + +@segments_v2_router.get( + "/{segment_id}/root_text", + status_code=status.HTTP_200_OK, + summary="Get Root Text for Segment", +) +async def get_root_text_for_segment_v2( + text_id: str, + segment_id: str, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=100)] = 10, +) -> V2SegmentRootTextResponse: + return await get_root_text_by_segment_id_from_openpecha( + text_id=text_id, + segment_id=segment_id, + skip=skip, + limit=limit, + ) @segments_v2_router.get( "/{segment_id}/translations", status_code=status.HTTP_200_OK, - summary="Get translations for a segment from OpenPecha", + summary="Get Translations for Segment", ) async def get_translations_for_segment_v2( segment_id: str, @@ -38,7 +67,7 @@ async def get_translations_for_segment_v2( @segments_v2_router.get( "/{segment_id}/commentaries", status_code=status.HTTP_200_OK, - summary="Get commentaries for a segment from OpenPecha", + summary="Get Commentaries for Segment", ) async def get_commentaries_for_segment_v2( segment_id: str, diff --git a/pecha_api/texts/segments/segments_repository.py b/pecha_api/texts/segments/segments_repository.py index 5fe1aaa4f..6b1529160 100644 --- a/pecha_api/texts/segments/segments_repository.py +++ b/pecha_api/texts/segments/segments_repository.py @@ -4,7 +4,7 @@ from pecha_api.constants import Constants from .segments_models import Segment -from .segments_response_models import CreateSegmentRequest, SegmentDTO, MappingResponse, SegmentUpdateRequest +from .segments_response_models import SegmentDTO, MappingResponse import logging from beanie.exceptions import CollectionWasNotInitialized from typing import List, Dict, Optional @@ -12,13 +12,6 @@ from starlette import status from pecha_api.error_contants import ErrorConstants -async def get_segments_by_pecha_segment_ids(pecha_segment_ids: List[str]) -> List[SegmentDTO]: - try: - return await Segment.get_segments_by_pecha_segment_ids(pecha_segment_ids=pecha_segment_ids) - except CollectionWasNotInitialized as e: - logging.debug(e) - return [] - async def get_segment_by_id(segment_id: str) -> SegmentDTO | None: try: segment = await Segment.get_segment_by_id(segment_id=segment_id) @@ -36,9 +29,6 @@ async def get_segments_by_text_id(text_id: str) -> List[SegmentDTO]: return [] -async def get_segment_details_by_id(segment_id: str): - return await Segment.get_segment_details(segment_id=segment_id) - async def check_segment_exists(segment_id: UUID) -> bool: try: is_segment_exists = await Segment.check_exists(segment_id=segment_id) @@ -74,22 +64,6 @@ async def get_segments_by_ids(segment_ids: List[str]) -> Dict[str, SegmentDTO]: return {} -async def create_segment(create_segment_request: CreateSegmentRequest) -> List[Segment]: - new_segment_list = [ - Segment( - pecha_segment_id=segment.pecha_segment_id, - text_id=create_segment_request.text_id, - content=segment.content, - mapping=segment.mapping, - type=segment.type - ) - for segment in create_segment_request.segments - ] - # Store the insert result but don't return it directly - await Segment.insert_many(new_segment_list) - - return new_segment_list - async def get_related_mapped_segments(parent_segment_id: str) -> List[SegmentDTO]: try: segments = await Segment.get_related_mapped_segments(parent_segment_id=parent_segment_id) @@ -125,25 +99,3 @@ async def get_related_mapped_segments_batch( except CollectionWasNotInitialized as e: logging.debug(e) return {} - -async def delete_segments_by_text_id(text_id: str): - try: - await Segment.delete_segment_by_text_id(text_id=text_id) - except CollectionWasNotInitialized as e: - logging.debug(e) - return False - - -async def update_segment_by_id(segment_update_request: SegmentUpdateRequest) -> SegmentDTO | None: - try: - for segment_update in segment_update_request.segments: - segment = await Segment.get_segment_by_pecha_segment_id(pecha_segment_id=segment_update.pecha_segment_id) - if not segment: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE) - segment.content = segment_update.content - await segment.save() - - return segment - except CollectionWasNotInitialized as e: - logging.debug(e) - return None \ No newline at end of file diff --git a/pecha_api/texts/segments/segments_response_models.py b/pecha_api/texts/segments/segments_response_models.py index 11324379b..41d81a4cf 100644 --- a/pecha_api/texts/segments/segments_response_models.py +++ b/pecha_api/texts/segments/segments_response_models.py @@ -1,26 +1,12 @@ from pydantic import BaseModel from typing import List, Optional -from sqlalchemy.orm import strategies from .segments_models import Mapping from .segments_enum import SegmentType from pecha_api.texts.texts_response_models import TextDTO -class CreateSegment(BaseModel): - pecha_segment_id: Optional[str] = None - content: str - pecha_segment_id: Optional[str] = None - type: SegmentType - mapping: Optional[List[Mapping]] = [] - - -class CreateSegmentRequest(BaseModel): - text_id: str - segments: List[CreateSegment] - - class MappingResponse(BaseModel): text_id: str segments: List[str] @@ -34,21 +20,10 @@ class SegmentDTO(BaseModel): mapping: Optional[List[MappingResponse]] = None text: Optional[TextDTO] = None -class SegmentUpdate(BaseModel): - pecha_segment_id: str - content: str - -class SegmentUpdateRequest(BaseModel): - pecha_text_id: str - segments: List[SegmentUpdate] - class MappedSegmentDTO(BaseModel): segment_id: str content: str -class SegmentResponse(BaseModel): - segments: List[SegmentDTO] - class ParentSegment(BaseModel): segment_id: str content: str @@ -86,9 +61,6 @@ class SegmentAdaptation(BaseModel): language: str content: str -class SegmentTranslationsResponse(BaseModel): - parent_segment: ParentSegment - translations: List[SegmentTranslation] # segment commentary models class SegmentCommentry(BaseModel): @@ -98,33 +70,6 @@ class SegmentCommentry(BaseModel): language: str count: int -class SegmentCommentariesResponse(BaseModel): - parent_segment: ParentSegment - commentaries: List[SegmentCommentry] - -# segment info models - -class SegmentInfosRequest(BaseModel): - text_id: str - -class RelatedText(BaseModel): - commentaries: Optional[int] = 0 - root_text: Optional[int] = 0 - -class Resources(BaseModel): - sheets: int - -class SegmentInfo(BaseModel): - segment_id: str - text_id: str - translations: Optional[int] = 0 - related_text: RelatedText - resources: Resources - -class SegmentInfoResponse(BaseModel): - segment_info: SegmentInfo - -# segment's root mapping models class MappedSegmentResponseDTO(BaseModel): segment_id: str @@ -136,11 +81,6 @@ class SegmentRootMapping(BaseModel): language: str segments: List[MappedSegmentResponseDTO] -class SegmentRootMappingResponse(BaseModel): - parent_segment: ParentSegment - segment_root_mapping: List[SegmentRootMapping] - - class V2RelatedSegmentItem(BaseModel): id: str content: Optional[str] = None @@ -160,6 +100,12 @@ class V2SegmentTranslationsResponse(BaseModel): limit: int has_more: bool = False +class V2SegmentRootTextResponse(BaseModel): + parent_segment: ParentSegment + root_text: List[V2SegmentTextGroup] + skip: int + limit: int + has_more: bool = False class V2SegmentCommentariesResponse(BaseModel): parent_segment: ParentSegment @@ -167,3 +113,15 @@ class V2SegmentCommentariesResponse(BaseModel): skip: int limit: int has_more: bool = False + + +class V2SegmentTextDetail(BaseModel): + text_id: str + title: str + language: Optional[str] = None + + +class V2SegmentResponse(BaseModel): + segment_id: str + content: str + text: Optional[V2SegmentTextDetail] = None diff --git a/pecha_api/texts/segments/segments_service.py b/pecha_api/texts/segments/segments_service.py index 7feab34aa..a16ace606 100644 --- a/pecha_api/texts/segments/segments_service.py +++ b/pecha_api/texts/segments/segments_service.py @@ -1,62 +1,19 @@ -from pecha_api.error_contants import ErrorConstants from .segments_repository import ( - create_segment, - get_segment_by_id, get_segments_by_ids, - get_related_mapped_segments, - get_related_mapped_segments_batch, - get_segments_by_text_id, - delete_segments_by_text_id, - update_segment_by_id ) -from ...users.users_service import verify_admin_access from .segments_response_models import ( - CreateSegmentRequest, - SegmentResponse, - MappingResponse, - SegmentDTO, - SegmentInfoResponse, - SegmentRootMappingResponse, - SegmentUpdateRequest + SegmentDTO, ) from pecha_api.cache.cache_enums import CacheType -from fastapi import HTTPException -from starlette import status - -from .segments_utils import SegmentUtils -from ..texts_utils import TextUtils - from typing import List, Dict -from .segments_response_models import ( - SegmentTranslationsResponse, - ParentSegment, - SegmentCommentariesResponse, - RelatedText, - Resources, - SegmentInfo, - SegmentRootMappingResponse -) - from .segments_cache_service import ( - set_segment_info_by_id_cache, - get_segment_info_by_id_cache, - get_segment_root_mapping_by_id_cache, - set_segment_root_mapping_by_id_cache, get_segments_details_by_ids_cache, set_segments_details_by_ids_cache, - delete_segments_details_by_ids_cache ) -from pecha_api.uploads.S3_utils import generate_presigned_access_url - -from .segments_enum import SegmentType -from ..texts_service import TextUtils -from ..texts_repository import get_text_by_pecha_text_id -from ...users.users_service import validate_user_exists - async def get_segments_details_by_ids(segment_ids: List[str]) -> Dict[str, SegmentDTO]: cached_data: Dict[str, SegmentDTO] = await get_segments_details_by_ids_cache(segment_ids=segment_ids, cache_type=CacheType.SEGMENTS_DETAILS) if cached_data is not None: @@ -67,162 +24,3 @@ async def get_segments_details_by_ids(segment_ids: List[str]) -> Dict[str, Segme await set_segments_details_by_ids_cache(segment_ids=segment_ids, cache_type=CacheType.SEGMENTS_DETAILS, data=segments) return segments - -async def get_segment_details_by_id(segment_id: str, text_details: bool = False) -> SegmentDTO: - - segment = await get_segment_by_id(segment_id=segment_id) - if not segment: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE) - mapping_responses: List[MappingResponse] = [ - MappingResponse(**mapping.model_dump()) for mapping in segment.mapping - ] - text = None - if text_details: - text = await TextUtils.get_text_details_by_id(text_id=segment.text_id) - - response = SegmentDTO( - id=str(segment.id), - pecha_segment_id=str(segment.pecha_segment_id), - text_id=segment.text_id, - content=segment.content, - mapping=mapping_responses, - type=segment.type, - text=text - ) - return response - -async def create_new_segment(create_segment_request: CreateSegmentRequest, token: str) -> SegmentResponse: - is_valid_user = validate_user_exists(token=token) - if is_valid_user: - await TextUtils.validate_text_exists(text_id=create_segment_request.text_id) - new_segment = await create_segment(create_segment_request=create_segment_request) - segments = [ - SegmentDTO( - id=str(segment.id), - pecha_segment_id=str(segment.pecha_segment_id), - text_id=segment.text_id, - content=segment.content, - mapping= [MappingResponse(**mapping.model_dump()) for mapping in segment.mapping], - type=segment.type - ) - for segment in new_segment - ] - return SegmentResponse(segments=segments) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE) - -async def get_translations_by_segment_id(segment_id: str) -> SegmentTranslationsResponse: - """ - Get translations for a given segment ID. - """ - - is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id) - if not is_valid_segment: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE) - parent_segment = await get_segment_by_id(segment_id=segment_id) - mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id) - translations = await SegmentUtils.filter_segment_mapping_by_type_or_text_id(segments=mapped_segments, type="version") - response = SegmentTranslationsResponse( - parent_segment=ParentSegment( - segment_id=str(parent_segment.id), - content=parent_segment.content - ), - translations=translations - ) - - return response - -async def get_commentaries_by_segment_id( - segment_id: str -) -> SegmentCommentariesResponse: - - is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id) - if not is_valid_segment: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE) - parent_segment = await get_segment_by_id(segment_id=segment_id) - mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id) - commentaries = await SegmentUtils.filter_segment_mapping_by_type_or_text_id(segments=mapped_segments, type="commentary") - response = SegmentCommentariesResponse( - parent_segment=ParentSegment( - segment_id=segment_id, - content=parent_segment.content - ), - commentaries=commentaries - ) - - return response - -async def get_info_by_segment_id(segment_id: str) -> SegmentInfoResponse: - - is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id) - if not is_valid_segment: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE) - - cache_data = await get_segment_info_by_id_cache(segment_id=segment_id, cache_type=CacheType.SEGMENT_INFO) - if cache_data: - return cache_data - segment = await get_segment_by_id(segment_id=segment_id) - text_detail=await TextUtils.get_text_details_by_id(text_id=segment.text_id) - mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id) - counts = await SegmentUtils.get_count_of_each_commentary_and_version(mapped_segments,parent_text=text_detail) - segment_root_mapping_count = await SegmentUtils.get_root_mapping_count(segment_id=segment_id) - response = SegmentInfoResponse( - segment_info= SegmentInfo( - segment_id=segment_id, - text_id=text_detail.id, - translations=counts["version"], - related_text=RelatedText( - commentaries=counts["commentary"], - root_text=segment_root_mapping_count - ), - resources=Resources( - sheets=0 - ) - ) - ) - await set_segment_info_by_id_cache( - segment_id = segment_id, - cache_type = CacheType.SEGMENT_INFO, - data = response - ) - return response - -async def get_root_text_mapping_by_segment_id(segment_id: str) -> SegmentRootMappingResponse: - - is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id) - if not is_valid_segment: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE) - parent_segment = await get_segment_by_id(segment_id=segment_id) - parent_text = await TextUtils.get_text_details_by_id(text_id=parent_segment.text_id) - mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id) - segment_root_mapping = await SegmentUtils.get_segment_root_mapping_details(segments=mapped_segments, parent_segment_text=parent_text) - response = SegmentRootMappingResponse( - parent_segment=ParentSegment( - segment_id=segment_id, - content=parent_segment.content - ), - segment_root_mapping=segment_root_mapping - ) - return response -async def fetch_segments_by_text_id(text_id: str) -> List[SegmentDTO]: - segments = await get_segments_by_text_id(text_id=text_id) - return segments - -async def remove_segments_by_text_id(text_id: str): - is_valid_text = await TextUtils.validate_text_exists(text_id=text_id) - if not is_valid_text: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE) - return await delete_segments_by_text_id(text_id=text_id) - - -async def update_segments_service(token: str, segment_update_request: SegmentUpdateRequest): - is_admin = verify_admin_access(token=token) - if is_admin: - text = await get_text_by_pecha_text_id(pecha_text_id=segment_update_request.pecha_text_id) - if not text: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE) - - return await update_segment_by_id(segment_update_request=segment_update_request) - - - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE) \ No newline at end of file diff --git a/pecha_api/texts/segments/segments_utils.py b/pecha_api/texts/segments/segments_utils.py index 3e21717dc..6b6181646 100644 --- a/pecha_api/texts/segments/segments_utils.py +++ b/pecha_api/texts/segments/segments_utils.py @@ -2,27 +2,21 @@ from uuid import UUID from fastapi import HTTPException from starlette import status -import bophono -from botok.tokenizers.wordtokenizer import WordTokenizer from pecha_api.error_contants import ErrorConstants from pecha_api.texts.texts_enums import TextType -from .segments_response_models import MappedSegmentDTO, MappedSegmentResponseDTO, SegmentDTO, SegmentCommentry, SegmentTranslation, SegmentTransliteration, SegmentAdaptation, SegmentRootMapping, SegmentRecitation +from .segments_response_models import MappedSegmentDTO, SegmentDTO, SegmentCommentry, SegmentTranslation, SegmentAdaptation from .segments_repository import ( check_segment_exists, check_all_segment_exists, get_segment_by_id, get_related_mapped_segments, ) -from ..texts_response_models import TextDTO from ..texts_repository import get_contents_by_id from pecha_api.constants import Constants -from ..groups.groups_service import ( - get_group_details -) from ..texts_utils import TextUtils from ..texts_response_models import ( DetailTableOfContent, @@ -36,8 +30,6 @@ SegmentTranslation, SegmentTransliteration, SegmentAdaptation, - SegmentRootMapping, - SegmentRecitation ) def _extract_segment_order(section, segment_order_map: Dict[str, int]): @@ -95,31 +87,6 @@ async def validate_segments_exists(segment_ids: List[str]) -> bool: detail=f"{ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE} {segment_ids}", ) return all_exists - - - @staticmethod - async def get_count_of_each_commentary_and_version( - segments: List[SegmentDTO], - parent_text: TextDTO - ) -> Dict[str, int]: - """ - Count the number of commentary and version segments in the provided list. - """ - count = {"commentary": 0, "version": 0} - unique_text_ids = set() - text_ids = [segment.text_id for segment in segments] - text_details_dict = await TextUtils.get_text_details_by_ids(text_ids=text_ids) - for segment in segments: - text_id = segment.text_id - if text_id in unique_text_ids: - continue - unique_text_ids.add(text_id) - text_detail = text_details_dict.get(text_id) - if text_detail and text_detail.type == "commentary": - count["commentary"] += 1 - elif text_detail and text_detail.type == "version" and text_detail.type == parent_text.type: - count["version"] += 1 - return count @staticmethod async def filter_segment_mapping_by_type_or_text_id( @@ -186,23 +153,6 @@ async def filter_segment_mapping_by_type_or_text_id( appended_commentary_text_ids.append(segment.text_id) return filtered_segments - - @staticmethod - async def get_root_mapping_count(segment_id: str) -> int: - segment = await get_segment_by_id(segment_id=segment_id) - text_id = segment.text_id - text_detail = await TextUtils.get_text_details_by_id(text_id=text_id) - group_id = text_detail.group_id - group_detail = await get_group_details(group_id=group_id) - if group_detail.type == "text": - return 0 - root_mapping_count = 0 - for mapping in segment.mapping: - text_detail = await TextUtils.get_text_details_by_id(text_id=mapping.text_id) - if text_detail.type == "commentary": - continue - root_mapping_count += 1 - return root_mapping_count @staticmethod async def get_mapped_segment_content_for_table_of_content( @@ -210,7 +160,7 @@ async def get_mapped_segment_content_for_table_of_content( ) -> DetailTableOfContent: """ Convert a TableOfContent model to a DetailTableOfContent model by enriching - each segment with detailed information fetched from get_segment_details_by_id. + each segment with content loaded from the segment repository. Args: table_of_content: The TableOfContent model to be converted @@ -281,42 +231,6 @@ async def process_section(section) -> DetailSection: return detail_table_of_content - @staticmethod - async def get_segment_root_mapping_details(segments: List[SegmentDTO], parent_segment_text: TextDTO) -> List[SegmentRootMapping]: - list_of_text_ids = [ - segment.text_id - for segment in segments - ] - texts_dict = await TextUtils.get_text_details_by_ids(text_ids=list_of_text_ids) - grouped_segments = await SegmentUtils._group_segment_content_by_text_id(segments=segments) - list_of_segment_root_mapping = [] - appended_text_ids = [] - - for segment in segments: - text_detail = texts_dict.get(segment.text_id) - if text_detail: - if segment.text_id in appended_text_ids: - continue - mapped_segments = [] - for segment_item in grouped_segments.get(segment.text_id, []): - mapped_segments.append(MappedSegmentResponseDTO( - segment_id=str(segment_item.id), - content=segment_item.content, - language=text_detail.language - )) - if text_detail.type == parent_segment_text.type: - continue - list_of_segment_root_mapping.append( - SegmentRootMapping( - text_id=segment.text_id, - title=text_detail.title, - language=text_detail.language, - segments=mapped_segments - ) - ) - appended_text_ids.append(segment.text_id) - return list_of_segment_root_mapping - @staticmethod async def _group_segment_content_by_text_id(segments: List[SegmentDTO]) -> Dict[str, List[SegmentDTO]]: grouped_segments = {} @@ -340,20 +254,4 @@ async def _group_segment_content_by_text_id(segments: List[SegmentDTO]) -> Dict[ segment_list.sort(key=lambda s: s.pecha_segment_id or "") return grouped_segments - - @staticmethod - def apply_bophono(segmentContent:str)->str: - options = { - 'aspirateLowTones': True - } - tokenizer = WordTokenizer() - tokens = tokenizer.tokenize(segmentContent) - token_text = [] - for token in tokens: - token_text.append(token.text) - kvpconverter = bophono.UnicodeToApi(schema="KVP", options = options) - kvp_ipa_list = [] - for segment in token_text: - kvp_ipa = kvpconverter.get_api(segment) - kvp_ipa_list.append(kvp_ipa) - return " ".join(kvp_ipa_list) + \ No newline at end of file diff --git a/pecha_api/texts/segments/segments_views.py b/pecha_api/texts/segments/segments_views.py deleted file mode 100644 index dae5224bc..000000000 --- a/pecha_api/texts/segments/segments_views.py +++ /dev/null @@ -1,87 +0,0 @@ -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from fastapi import APIRouter, Depends -from starlette import status - -from typing import Annotated - -from .segments_service import ( - create_new_segment, - get_translations_by_segment_id, - get_commentaries_by_segment_id, - get_segment_details_by_id, - get_info_by_segment_id, - get_root_text_mapping_by_segment_id, - update_segments_service -) -from .segments_response_models import ( - CreateSegmentRequest, - SegmentDTO, - SegmentResponse, - SegmentInfoResponse, - SegmentTranslationsResponse, - SegmentCommentariesResponse, - SegmentUpdateRequest -) - -oauth2_scheme = HTTPBearer() -segment_router = APIRouter( - prefix="/segments", - tags=["Segments"] -) - -from fastapi import Query - - -@segment_router.post("", status_code=status.HTTP_201_CREATED) -async def create_segment( - create_segment_request: CreateSegmentRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], -) -> SegmentResponse: - return await create_new_segment(create_segment_request=create_segment_request, token=authentication_credential.credentials) - -@segment_router.get("/{segment_id}", status_code=status.HTTP_200_OK) -async def get_segment( - segment_id: str, - text_details: bool = Query(default=False) -) -> SegmentDTO: - return await get_segment_details_by_id(segment_id=segment_id, text_details=text_details) - -@segment_router.get("/{segment_id}/info", status_code=status.HTTP_200_OK) -async def get_info_for_segment( - segment_id: str -) -> SegmentInfoResponse: - return await get_info_by_segment_id(segment_id=segment_id) - -@segment_router.get("/{segment_id}/root_text", status_code=status.HTTP_200_OK) -async def get_root_text_for_segment( - segment_id: str -): - return await get_root_text_mapping_by_segment_id(segment_id=segment_id) - -@segment_router.get("/{segment_id}/translations", status_code=status.HTTP_200_OK) -async def get_translations_for_segment( - segment_id: str -) -> SegmentTranslationsResponse: - return await get_translations_by_segment_id( - segment_id=segment_id - ) - -@segment_router.get("/{segment_id}/commentaries", status_code=status.HTTP_200_OK) -async def get_commentaries_for_segment( - segment_id: str -) -> SegmentCommentariesResponse: - return await get_commentaries_by_segment_id( - segment_id=segment_id - ) - - -@segment_router.put("", status_code=status.HTTP_200_OK) -async def update_segment( - segment_update_request: SegmentUpdateRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], -): - return await update_segments_service( - token=authentication_credential.credentials, - segment_update_request=segment_update_request, - - ) \ No newline at end of file diff --git a/pecha_api/texts/texts_cache_service.py b/pecha_api/texts/texts_cache_service.py index 8c51cd884..ea4eaca67 100644 --- a/pecha_api/texts/texts_cache_service.py +++ b/pecha_api/texts/texts_cache_service.py @@ -73,26 +73,6 @@ async def set_table_of_contents_by_text_id_cache(text_id: str = None, language: cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) -async def get_table_of_content_by_sheet_id_cache(sheet_id: str = None, cache_type: CacheType = None) -> Optional[TableOfContent]: - payload = [sheet_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: TableOfContent = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = TableOfContent(**cache_data) - return cache_data - -async def set_table_of_content_by_sheet_id_cache(sheet_id: str = None, cache_type: CacheType = None, data: TableOfContent = None): - #Set table of content by sheet id cache asynchronously. - payload = [sheet_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_SHEET_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def delete_table_of_content_by_sheet_id_cache(sheet_id: str = None, cache_type: CacheType = None): - payload = [sheet_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - await clear_cache(hash_key = hashed_key) - async def get_text_versions_by_group_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TextVersionResponse: #Get text versions by group_id cache asynchronously. payload = [text_id, language, skip, limit, cache_type] @@ -131,93 +111,47 @@ async def delete_text_details_by_id_cache(text_id: str = None, cache_type: Cache async def update_text_details_cache(text_id: str, updated_text_data: TextDTO, cache_type: CacheType = CacheType.TEXT_DETAIL) -> bool: - #Update cached text details for a specific text_id with new data. Handles both text and sheet instances. try: - # Determine cache types based on whether this is a text or sheet - is_sheet = cache_type == CacheType.SHEET_DETAIL - - # Update primary cache (TEXT_DETAIL for texts, SHEET_DETAIL for sheets) primary_cache_payload = [text_id, cache_type] primary_cache_hash_key = Utils.generate_hash_key(payload=primary_cache_payload) - # Update TEXTS_BY_ID_OR_COLLECTION cache (used for both texts and sheets) texts_by_id_payload = [text_id, None, None, None, None, CacheType.TEXTS_BY_ID_OR_COLLECTION] texts_by_id_hash_key = Utils.generate_hash_key(payload=texts_by_id_payload) - # Try to update existing cache entries update_results = [] - # Update primary cache (text detail or sheet detail) cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") is_primary_cache_updated = await update_cache(hash_key=primary_cache_hash_key, value=updated_text_data, cache_time_out=cache_time_out) update_results.append(is_primary_cache_updated) - # Update texts by id cache is_texts_by_id_updated = await update_cache(hash_key=texts_by_id_hash_key, value=updated_text_data, cache_time_out=cache_time_out) update_results.append(is_texts_by_id_updated) - # For sheets, also try to update table of content cache - if is_sheet: - try: - from .texts_service import get_table_of_content_by_sheet_id - updated_table_of_content = await get_table_of_content_by_sheet_id(sheet_id=text_id) - if updated_table_of_content: - toc_payload = [text_id, CacheType.SHEET_TABLE_OF_CONTENT] - toc_hash_key = Utils.generate_hash_key(payload=toc_payload) - is_toc_updated = await update_cache(hash_key=toc_hash_key, value=updated_table_of_content, cache_time_out=cache_time_out) - update_results.append(is_toc_updated) - except Exception as e: - logging.warning(f"Could not update table of content cache for sheet {text_id}: {str(e)}") - - # If direct updates failed, fallback to invalidation to ensure cache consistency if not any(update_results): await invalidate_text_cache_on_update(text_id=text_id, cache_type=cache_type) return True except Exception as e: logging.error(f"Error updating text details cache for text_id {text_id}: {str(e)}", exc_info=True) - # Fallback to cache invalidation to maintain consistency await invalidate_text_cache_on_update(text_id=text_id, cache_type=cache_type) return False async def invalidate_text_cache_on_update(text_id: str, cache_type: CacheType = CacheType.TEXT_DETAIL) -> bool: - #Invalidate all cache entries related to a text when it's updated. Handles both text and sheet instances. try: - # Generate hash keys for all possible cache entries related to this text/sheet cache_keys_to_invalidate = [] - # Determine if this is a sheet - is_sheet = cache_type == CacheType.SHEET_DETAIL - - # Primary cache (TEXT_DETAIL for texts, SHEET_DETAIL for sheets) primary_cache_payload = [text_id, cache_type] cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=primary_cache_payload)) - # TEXTS_BY_ID_OR_COLLECTION cache (used for both texts and sheets) texts_by_id_payload = [text_id, None, None, None, None, CacheType.TEXTS_BY_ID_OR_COLLECTION] cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=texts_by_id_payload)) - if is_sheet: - # Sheet-specific cache entries - - # SHEET_TABLE_OF_CONTENT cache - sheet_toc_payload = [text_id, CacheType.SHEET_TABLE_OF_CONTENT] - cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=sheet_toc_payload)) - - # SHEETS cache (general sheets listing) - sheets_payload = [None, None, None, None, None, None, CacheType.SHEETS] - cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=sheets_payload)) - else: - # Text-specific cache entries - - # TEXT_TABLE_OF_CONTENTS cache - toc_payload = [text_id, None, None, None, CacheType.TEXT_TABLE_OF_CONTENTS] - cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=toc_payload)) - - # TEXT_VERSIONS cache - versions_payload = [text_id, None, None, None, CacheType.TEXT_VERSIONS] - cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=versions_payload)) + toc_payload = [text_id, None, None, None, CacheType.TEXT_TABLE_OF_CONTENTS] + cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=toc_payload)) + + versions_payload = [text_id, None, None, None, CacheType.TEXT_VERSIONS] + cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=versions_payload)) # Invalidate specific cache keys await invalidate_multiple_cache_keys(hash_keys=cache_keys_to_invalidate) diff --git a/pecha_api/texts/texts_enums.py b/pecha_api/texts/texts_enums.py index 9d2ef9dd6..2f9b5109b 100644 --- a/pecha_api/texts/texts_enums.py +++ b/pecha_api/texts/texts_enums.py @@ -12,6 +12,16 @@ class TextType(Enum): class TextTypes(Enum): VERSIONS = "versions" +class SortOrder(Enum): + ASC = "asc" + DESC = "desc" + + +class SortBy(Enum): + CREATED_DATE = "created_date" + PUBLISHED_DATE = "published_date" + + class PaginationDirection(Enum): NEXT = "next" PREVIOUS = "previous" diff --git a/pecha_api/texts/texts_models.py b/pecha_api/texts/texts_models.py index efea44a2c..cb5b9baea 100644 --- a/pecha_api/texts/texts_models.py +++ b/pecha_api/texts/texts_models.py @@ -7,11 +7,6 @@ from pydantic import Field from beanie import Document -from pecha_api.sheets.sheets_enum import ( - SortBy, - SortOrder -) - from .texts_enums import TextType from .texts_response_models import TextDTO, TableOfContentType @@ -211,56 +206,3 @@ async def delete_text_by_id(cls, text_id: UUID): return await cls.find_one(cls.id == text_id).delete() - @classmethod - async def get_sheets( - cls, - published_by: Optional[str] = None, - is_published: Optional[bool] = None, - sort_by: Optional[SortBy] = None, - sort_order: Optional[SortOrder] = None, - skip: int = 0, - limit: int = 10 - ) -> List["Text"]: - query = {"type": TextType.SHEET} - - if published_by is not None: - query["published_by"] = published_by - - if is_published is not None: - query["is_published"] = is_published - - mongo_query = cls.find(query) - if sort_by: - field = { - SortBy.CREATED_DATE: "created_date", - SortBy.PUBLISHED_DATE: "published_date", - } - sort_field = field.get(sort_by) - if sort_field: - if sort_order == SortOrder.DESC: - sort_string = f"-{sort_field}" - else: - sort_string = sort_field - - mongo_query = mongo_query.sort(sort_string) - - mongo_query = mongo_query.skip(skip).limit(limit) - - - texts = await mongo_query.to_list() - - return texts - - - @classmethod - async def get_published_sheets_count_from_db( - cls, - email: Optional[str] = None - ) -> int: - query = { - "type": TextType.SHEET, - "is_published": True - } - if email is not None: - query["published_by"] = email - return await cls.find(query).count() diff --git a/pecha_api/texts/texts_repository.py b/pecha_api/texts/texts_repository.py index 8a9ccfaf9..d5417d098 100644 --- a/pecha_api/texts/texts_repository.py +++ b/pecha_api/texts/texts_repository.py @@ -12,10 +12,6 @@ TextDTO, UpdateTextRequest ) -from pecha_api.sheets.sheets_enum import ( - SortBy, - SortOrder -) from .texts_models import Text, TableOfContent from datetime import datetime, timezone from pecha_api.utils import Utils @@ -69,25 +65,6 @@ async def get_texts_by_ids(text_ids: List[str]) -> Dict[str, TextDTO]: for text in list_of_texts_detail } -async def fetch_sheets_from_db( - published_by: Optional[str] = None, - is_published: Optional[bool] = None, - sort_by: Optional[SortBy] = None, - sort_order: Optional[SortOrder] = None, - skip: int = 0, - limit: int = 10 -) -> List[Text]: - sheets = await Text.get_sheets( - published_by=published_by, - is_published=is_published, - sort_by=sort_by, - sort_order=sort_order, - skip=skip, - limit=limit - ) - return sheets - - async def check_text_exists(text_id: UUID) -> bool: try: is_text_exits = await Text.check_exists(text_id=text_id) diff --git a/pecha_api/texts/texts_service.py b/pecha_api/texts/texts_service.py index caeb2f71e..06159ed99 100644 --- a/pecha_api/texts/texts_service.py +++ b/pecha_api/texts/texts_service.py @@ -5,17 +5,14 @@ from pecha_api.http_message_utils import handle_http_status_error, handle_request_error from .texts_repository import ( get_all_texts_by_collection, - get_texts_by_collection, get_texts_by_group_id, create_text, create_table_of_content_detail, get_contents_by_id, get_table_of_content_by_content_id, - get_sections_count_of_table_of_content, delete_table_of_content_by_text_id, update_text_details_by_id, delete_text_by_id, - fetch_sheets_from_db, get_all_texts_by_collection, get_all_recitation_texts_by_collection, get_texts_by_pecha_text_ids, @@ -63,25 +60,13 @@ ) from .segments.segments_models import Segment from pecha_api.texts.texts_cache_service import ( - set_text_details_cache, - get_text_details_cache, get_text_by_text_id_or_collection_cache, set_text_by_text_id_or_collection_cache, - get_table_of_contents_by_text_id_cache, - set_table_of_contents_by_text_id_cache, - get_text_versions_by_group_id_cache, set_text_versions_by_group_id_cache, - get_table_of_content_by_sheet_id_cache, - set_table_of_content_by_sheet_id_cache, - delete_table_of_content_by_sheet_id_cache, update_text_details_cache, invalidate_text_cache_on_update ) from .segments.segments_repository import get_segments_by_text_id -from pecha_api.sheets.sheets_enum import ( - SortBy, - SortOrder -) from pecha_api.cache.cache_enums import CacheType from .texts_utils import TextUtils @@ -95,7 +80,7 @@ from typing import List, Dict, Optional, Tuple, Set from pecha_api.config import get from pecha_api.utils import Utils -from .texts_enums import PaginationDirection, LANGUAGE_ORDERS, TextType, TextTypes +from .texts_enums import PaginationDirection, TextType, TextTypes import logging import httpx @@ -216,37 +201,6 @@ async def get_titles_and_ids_by_query( return [TitleSearchResult(id=str(text.id), title=text.title) for text in texts] -async def get_sheet(published_by: Optional[str] = None, is_published: Optional[bool] = None, sort_by: Optional[SortBy] = None, sort_order: Optional[SortOrder] = None, skip: int = 0, limit: int = 10): - - sheets = await fetch_sheets_from_db( - published_by=published_by, - is_published=is_published, - sort_by=sort_by, - sort_order=sort_order, - skip=skip, - limit=limit - ) - return sheets - -async def get_table_of_content_by_sheet_id(sheet_id: str) -> Optional[TableOfContent]: - cached_data: TableOfContent = await get_table_of_content_by_sheet_id_cache(sheet_id=sheet_id, cache_type=CacheType.SHEET_TABLE_OF_CONTENT) - if cached_data is not None: - return cached_data - - table_of_content = None - is_valid_sheet: bool = await TextUtils.validate_text_exists(text_id=sheet_id) - if not is_valid_sheet: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE) - - table_of_contents: List[TableOfContent] = await get_contents_by_id(text_id=sheet_id) - if len(table_of_contents) > 0 and table_of_contents[0] is not None: - table_of_content: TableOfContent = table_of_contents[0] - - if table_of_content is not None: - await set_table_of_content_by_sheet_id_cache(sheet_id=sheet_id, cache_type=CacheType.SHEET_TABLE_OF_CONTENT, data=table_of_content) - - return table_of_content - async def get_table_of_contents_by_text_id(text_id: str, language: str = None, skip: int = 0, limit: int = 10) -> TableOfContentResponse: if language is None: diff --git a/tests/collection/test_collection_views.py b/tests/collection/test_collection_views.py index 37e44c477..72fb64362 100644 --- a/tests/collection/test_collection_views.py +++ b/tests/collection/test_collection_views.py @@ -275,50 +275,3 @@ async def test_delete_collection_with_children(): assert response.status_code == status.HTTP_400_BAD_REQUEST - -# Tests for GET /text-uploader/collections/{pecha_collection_id} endpoint - -@pytest.mark.asyncio -async def test_get_collection_by_pecha_collection_id_success(): - # Test successful retrieval of collection by pecha_collection_id - pecha_collection_id = "pecha_60d21b4667d0d8992e610c85" - expected_collection_id = "60d21b4667d0d8992e610c85" - - with patch("pecha_api.text_uploader.collections.uploader_collections_views.get_collection_by_pecha_collection_id_service", - new_callable=AsyncMock, return_value=expected_collection_id): - - response = client.get(f"/text-uploader/collections/{pecha_collection_id}") - - assert response.status_code == status.HTTP_200_OK - assert response.json() == expected_collection_id - - -@pytest.mark.asyncio -async def test_get_collection_by_pecha_collection_id_not_found(): - # Test retrieval when pecha_collection_id doesn't exist - pecha_collection_id = "nonexistent_pecha_id" - - with patch("pecha_api.text_uploader.collections.uploader_collections_views.get_collection_by_pecha_collection_id_service", - new_callable=AsyncMock, return_value=None): - - response = client.get(f"/text-uploader/collections/{pecha_collection_id}") - - assert response.status_code == status.HTTP_200_OK - assert response.json() is None - - -@pytest.mark.asyncio -async def test_get_collection_by_pecha_collection_id_service_error(): - # Test handling of service errors - pecha_collection_id = "pecha_error_id" - - with patch("pecha_api.text_uploader.collections.uploader_collections_views.get_collection_by_pecha_collection_id_service", - new_callable=AsyncMock, side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - )): - - response = client.get(f"/text-uploader/collections/{pecha_collection_id}") - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - diff --git a/tests/recitations/test_recitations_services.py b/tests/recitations/test_recitations_services.py index faf419f0c..15aa8f540 100644 --- a/tests/recitations/test_recitations_services.py +++ b/tests/recitations/test_recitations_services.py @@ -379,15 +379,15 @@ def create_mock_segment_recitation(segment_id: str, language: str = "en", conten content=content ) - @patch('pecha_api.recitations.recitations_services.get_segment_details_by_id') - @patch('pecha_api.recitations.recitations_services.get_related_mapped_segments') + @patch('pecha_api.recitations.recitations_services.get_segments_details_by_ids') + @patch('pecha_api.recitations.recitations_services.get_related_mapped_segments_batch') @patch('pecha_api.recitations.recitations_services.SegmentUtils.filter_segment_mapping_by_type_or_text_id') @pytest.mark.asyncio async def test_segments_mapping_by_toc_empty_table_of_contents( self, mock_filter_segments, mock_get_related_segments, - mock_get_segment_details + mock_get_segments_details, ): """Test mapping with empty table of contents.""" request = RecitationDetailsRequest( @@ -406,7 +406,7 @@ async def test_segments_mapping_by_toc_empty_table_of_contents( assert result == [] # Verify no mock calls were made - mock_get_segment_details.assert_not_called() + mock_get_segments_details.assert_not_called() mock_get_related_segments.assert_not_called() mock_filter_segments.assert_not_called() diff --git a/tests/segments/test_segments_cache_service.py b/tests/segments/test_segments_cache_service.py deleted file mode 100644 index 8f39d4982..000000000 --- a/tests/segments/test_segments_cache_service.py +++ /dev/null @@ -1,183 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock - -from pecha_api.texts.segments.segments_cache_service import ( - get_segment_details_by_id_cache, - set_segment_details_by_id_cache, - get_segment_root_mapping_by_id_cache, - set_segment_root_mapping_by_id_cache, - get_segment_commentaries_by_id_cache, - set_segment_commentaries_by_id_cache, - get_segment_translations_by_id_cache, - set_segment_translations_by_id_cache -) - -from pecha_api.texts.segments.segments_response_models import ( - SegmentDTO, - SegmentRootMappingResponse, - ParentSegment, - SegmentRootMapping, - SegmentCommentariesResponse, - SegmentTranslation, - SegmentTranslationsResponse -) -from pecha_api.texts.segments.segments_enum import SegmentType - -@pytest.mark.asyncio -async def test_get_segment_details_by_id_cache_success(): - mock_segment = SegmentDTO( - id="segment_id", - text_id="text_id", - mapping=[], - content="content", - type=SegmentType.CONTENT - ) - - with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_segment): - response = await get_segment_details_by_id_cache(segment_id="segment_id", text_details=True) - - assert response is not None - assert isinstance(response, SegmentDTO) - assert response.id == "segment_id" - -@pytest.mark.asyncio -async def test_set_segment_details_by_id_cache_success(): - - mock_segment=SegmentDTO( - id="segment_id", - text_id="text_id", - mapping=[], - content="content", - type=SegmentType.CONTENT - ) - - with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None): - response = await set_segment_details_by_id_cache(segment_id="segment_id", text_details=True, data=mock_segment) - -@pytest.mark.asyncio -async def test_get_segment_root_mapping_by_id_cache_success(): - mock_segment_root_mapping = SegmentRootMappingResponse( - parent_segment=ParentSegment( - segment_id="segment_id", - content="content" - ), - segment_root_mapping=[] - ) - - with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_segment_root_mapping): - - response = await get_segment_root_mapping_by_id_cache(segment_id="segment_id") - - assert response is not None - assert isinstance(response, SegmentRootMappingResponse) - assert response.parent_segment is not None - assert isinstance(response.parent_segment, ParentSegment) - assert response.parent_segment.segment_id == "segment_id" - assert response.parent_segment.content == "content" - assert response.segment_root_mapping == [] - -@pytest.mark.asyncio -async def test_set_segment_root_mapping_by_id_cache_success(): - mock_segment_root_mapping = SegmentRootMappingResponse( - parent_segment=ParentSegment( - segment_id="segment_id", - content="content" - ), - segment_root_mapping=[] - ) - - with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None): - - response = await set_segment_root_mapping_by_id_cache(segment_id="segment_id", data=mock_segment_root_mapping) - -@pytest.mark.asyncio -async def test_get_segment_commentaries_by_id_cache_success(): - mock_segment_commentaries = SegmentCommentariesResponse( - parent_segment=ParentSegment( - segment_id="segment_id", - content="content" - ), - commentaries=[] - ) - - with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_segment_commentaries): - - response = await get_segment_commentaries_by_id_cache(segment_id="segment_id") - - assert response is not None - assert isinstance(response, SegmentCommentariesResponse) - assert response.parent_segment is not None - assert isinstance(response.parent_segment, ParentSegment) - assert response.parent_segment.segment_id == "segment_id" - assert response.parent_segment.content == "content" - assert response.commentaries == [] - -@pytest.mark.asyncio -async def test_set_segment_commentaries_by_id_cache_success(): - mock_segment_commentaries = SegmentCommentariesResponse( - parent_segment=ParentSegment( - segment_id="segment_id", - content="content" - ), - commentaries=[] - ) - - with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None): - - response = await set_segment_commentaries_by_id_cache(segment_id="segment_id", data=mock_segment_commentaries) - -@pytest.mark.asyncio -async def test_get_segment_translations_by_id_cache_success(): - mock_data = SegmentTranslationsResponse( - parent_segment=ParentSegment( - segment_id="segment_id", - content="content" - ), - translations=[ - SegmentTranslation( - segment_id="segment_id", - text_id="text_id", - title="title", - source="source", - language="language", - content="content" - ) - ] - ) - - with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_data): - - response = await get_segment_translations_by_id_cache(segment_id="segment_id") - - assert response is not None - assert isinstance(response, SegmentTranslationsResponse) - assert response.parent_segment is not None - assert isinstance(response.parent_segment, ParentSegment) - assert response.parent_segment.segment_id == "segment_id" - assert response.parent_segment.content == "content" - assert response.translations is not None - assert len(response.translations) == 1 - assert response.translations[0].segment_id == "segment_id" - -@pytest.mark.asyncio -async def test_set_segment_translations_by_id_cache_success(): - mock_data = SegmentTranslationsResponse( - parent_segment=ParentSegment( - segment_id="segment_id", - content="content" - ), - translations=[ - SegmentTranslation( - segment_id="segment_id", - text_id="text_id", - title="title", - source="source", - language="language", - content="content" - ) - ] - ) - - with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None): - - await set_segment_translations_by_id_cache(segment_id="segment_id", data=mock_data) diff --git a/tests/segments/test_segments_openpecha_service.py b/tests/segments/test_segments_openpecha_service.py index cdf2cffa2..217d31824 100644 --- a/tests/segments/test_segments_openpecha_service.py +++ b/tests/segments/test_segments_openpecha_service.py @@ -4,17 +4,22 @@ from pecha_api.texts.segments.segments_openpecha_service import ( _classify_text, - get_translations_by_segment_id_from_openpecha, get_commentaries_by_segment_id_from_openpecha, + get_openpecha_segment_details_by_id, + get_root_text_by_segment_id_from_openpecha, + get_translations_by_segment_id_from_openpecha, ) from pecha_api.texts.segments.segments_response_models import ( V2SegmentCommentariesResponse, + V2SegmentResponse, + V2SegmentRootTextResponse, V2SegmentTranslationsResponse, ) PARENT_SEGMENT_ID = "parent-seg-1" TRANSLATION_TEXT_ID = "text-trans-1" COMMENTARY_TEXT_ID = "text-comm-1" +ROOT_TEXT_ID = "1AIZhR8IBkX4WMfNYkmpc" def _translation_text(text_id: str) -> dict: @@ -37,6 +42,16 @@ def _commentary_text(text_id: str) -> dict: } +def _root_text(text_id: str) -> dict: + return { + "id": text_id, + "title": {"bo": f"Root text {text_id}"}, + "language": "bo", + "translation_of": None, + "commentary_of": None, + } + + def _related_item(segment_id: str, text_id: str) -> dict: return { "id": segment_id, @@ -72,6 +87,82 @@ def test_returns_none_for_empty_payload(self): assert _classify_text(None) is None +class TestGetOpenpechaSegmentDetailsById: + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content", + new_callable=AsyncMock, + ) + async def test_returns_segment_content_without_text_details( + self, + mock_fetch_content, + mock_fetch_details, + mock_fetch_text, + ): + mock_fetch_content.return_value = "Segment content" + mock_fetch_details.return_value = {"text_id": "unused-text-ref"} + mock_fetch_text.return_value = None + + result = await get_openpecha_segment_details_by_id(segment_id="seg-1") + + assert isinstance(result, V2SegmentResponse) + assert result.segment_id == "seg-1" + assert result.content == "Segment content" + assert result.text is None + mock_fetch_content.assert_awaited_once_with("seg-1") + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content", + new_callable=AsyncMock, + ) + async def test_returns_text_details_when_segment_details_include_text_id( + self, + mock_fetch_content, + mock_fetch_details, + mock_fetch_text, + ): + mock_fetch_content.return_value = "Segment content" + mock_fetch_details.return_value = {"text_id": ROOT_TEXT_ID} + mock_fetch_text.return_value = _root_text(ROOT_TEXT_ID) + + result = await get_openpecha_segment_details_by_id(segment_id="seg-1") + + assert result.text is not None + assert result.text.text_id == ROOT_TEXT_ID + assert result.text.title == f"Root text {ROOT_TEXT_ID}" + assert result.text.language == "bo" + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content", + new_callable=AsyncMock, + ) + async def test_raises_not_found_when_content_missing(self, mock_fetch_content): + mock_fetch_content.return_value = None + + with pytest.raises(HTTPException) as exc_info: + await get_openpecha_segment_details_by_id(segment_id="missing-seg") + + assert exc_info.value.status_code == 404 + + class TestGetTranslationsBySegmentIdFromOpenpecha: @pytest.mark.asyncio @patch( @@ -398,3 +489,198 @@ async def text_side_effect(text_id: str): assert len(result.translations) == 1 assert result.translations[0].text_id == TRANSLATION_TEXT_ID assert len(result.translations[0].segments) == 1 + + +class TestGetRootTextBySegmentIdFromOpenpecha: + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments", + new_callable=AsyncMock, + ) + async def test_returns_root_text_segments_for_matching_text_id( + self, + mock_fetch_related, + mock_fetch_content, + mock_fetch_text, + ): + mock_fetch_related.return_value = _related_page( + [ + _related_item("root-seg-1", ROOT_TEXT_ID), + _related_item("root-seg-2", ROOT_TEXT_ID), + _related_item("seg-trans-1", TRANSLATION_TEXT_ID), + ], + has_more=False, + ) + + async def content_side_effect(segment_id: str): + return { + PARENT_SEGMENT_ID: "Parent segment content", + "root-seg-1": "Root segment 1", + "root-seg-2": "Root segment 2", + }.get(segment_id) + + mock_fetch_content.side_effect = content_side_effect + mock_fetch_text.return_value = _root_text(ROOT_TEXT_ID) + + result = await get_root_text_by_segment_id_from_openpecha( + text_id=ROOT_TEXT_ID, + segment_id=PARENT_SEGMENT_ID, + skip=0, + limit=10, + ) + + assert isinstance(result, V2SegmentRootTextResponse) + assert result.parent_segment.segment_id == PARENT_SEGMENT_ID + assert result.parent_segment.content == "Parent segment content" + assert len(result.root_text) == 1 + assert result.root_text[0].text_id == ROOT_TEXT_ID + assert result.root_text[0].title == f"Root text {ROOT_TEXT_ID}" + assert result.root_text[0].language == "bo" + assert len(result.root_text[0].segments) == 2 + assert result.root_text[0].segments[0].id == "root-seg-1" + assert result.root_text[0].segments[0].content == "Root segment 1" + assert result.has_more is False + + mock_fetch_related.assert_awaited_once_with( + segment_id=PARENT_SEGMENT_ID, + limit=100, + offset=0, + ) + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments", + new_callable=AsyncMock, + ) + async def test_paginates_matching_root_text_segments( + self, + mock_fetch_related, + mock_fetch_content, + mock_fetch_text, + ): + async def related_side_effect(segment_id: str, limit: int, offset: int): + if offset == 0: + return _related_page( + [_related_item(f"root-seg-{index}", ROOT_TEXT_ID) for index in range(1, 3)], + has_more=True, + offset=0, + limit=100, + ) + return _related_page( + [_related_item(f"root-seg-{index}", ROOT_TEXT_ID) for index in range(3, 5)], + has_more=False, + offset=100, + limit=100, + ) + + mock_fetch_related.side_effect = related_side_effect + + async def content_side_effect(segment_id: str): + return { + PARENT_SEGMENT_ID: "Parent segment content", + "root-seg-1": "Root segment 1", + "root-seg-2": "Root segment 2", + "root-seg-3": "Root segment 3", + "root-seg-4": "Root segment 4", + }.get(segment_id) + + mock_fetch_content.side_effect = content_side_effect + mock_fetch_text.return_value = _root_text(ROOT_TEXT_ID) + + result = await get_root_text_by_segment_id_from_openpecha( + text_id=ROOT_TEXT_ID, + segment_id=PARENT_SEGMENT_ID, + skip=1, + limit=2, + ) + + assert len(result.root_text) == 1 + assert len(result.root_text[0].segments) == 2 + assert result.root_text[0].segments[0].id == "root-seg-2" + assert result.root_text[0].segments[1].id == "root-seg-3" + assert result.skip == 1 + assert result.limit == 2 + assert result.has_more is True + assert mock_fetch_related.await_count == 2 + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments", + new_callable=AsyncMock, + ) + async def test_returns_empty_root_text_when_no_matching_text_id( + self, + mock_fetch_related, + mock_fetch_content, + ): + mock_fetch_related.return_value = _related_page( + [_related_item("seg-trans-1", TRANSLATION_TEXT_ID)] + ) + mock_fetch_content.return_value = "Parent content" + + result = await get_root_text_by_segment_id_from_openpecha( + text_id=ROOT_TEXT_ID, + segment_id=PARENT_SEGMENT_ID, + ) + + assert result.root_text == [] + assert result.has_more is False + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments", + new_callable=AsyncMock, + ) + async def test_stops_fetching_after_enough_matches( + self, + mock_fetch_related, + mock_fetch_content, + ): + mock_fetch_related.side_effect = [ + _related_page( + [_related_item(f"root-seg-{index}", ROOT_TEXT_ID) for index in range(1, 12)], + has_more=True, + ), + ] + + mock_fetch_content.return_value = "content" + + with patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + return_value=_root_text(ROOT_TEXT_ID), + ): + result = await get_root_text_by_segment_id_from_openpecha( + text_id=ROOT_TEXT_ID, + segment_id=PARENT_SEGMENT_ID, + skip=0, + limit=10, + ) + + assert len(result.root_text[0].segments) == 10 + assert result.has_more is True + mock_fetch_related.assert_awaited_once() diff --git a/tests/segments/test_segments_openpecha_views.py b/tests/segments/test_segments_openpecha_views.py index a75b58049..7be0b94d6 100644 --- a/tests/segments/test_segments_openpecha_views.py +++ b/tests/segments/test_segments_openpecha_views.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from fastapi import HTTPException from fastapi.testclient import TestClient @@ -8,16 +8,72 @@ ParentSegment, V2RelatedSegmentItem, V2SegmentCommentariesResponse, + V2SegmentResponse, + V2SegmentTextDetail, V2SegmentTextGroup, V2SegmentTranslationsResponse, ) client = TestClient(api) +SEGMENTS_ROOT = "/segments" + + +class TestSegmentsV2GetSegmentEndpoint: + @patch( + "pecha_api.texts.segments.segments_openpecha_views.get_openpecha_segment_details_by_id", + new_callable=AsyncMock, + ) + def test_get_segment_without_text_id(self, mock_service): + mock_service.return_value = V2SegmentResponse( + segment_id="seg-1", + content="Segment content", + ) + + response = client.get(f"{SEGMENTS_ROOT}/seg-1") + + assert response.status_code == 200 + data = response.json() + assert data["segment_id"] == "seg-1" + assert data["content"] == "Segment content" + assert data["text"] is None + + mock_service.assert_awaited_once_with( + segment_id="seg-1", + ) + + @patch( + "pecha_api.texts.segments.segments_openpecha_views.get_openpecha_segment_details_by_id", + new_callable=AsyncMock, + ) + def test_get_segment_returns_text_when_service_includes_detail(self, mock_service): + mock_service.return_value = V2SegmentResponse( + segment_id="seg-1", + content="Segment content", + text=V2SegmentTextDetail( + text_id="text-1", + title="Root Text Title", + language="bo", + ), + ) + + response = client.get(f"{SEGMENTS_ROOT}/seg-1") + + assert response.status_code == 200 + data = response.json() + assert data["text"]["text_id"] == "text-1" + assert data["text"]["title"] == "Root Text Title" + assert data["text"]["language"] == "bo" + + mock_service.assert_awaited_once_with( + segment_id="seg-1", + ) + class TestSegmentsV2TranslationsEndpoint: @patch( - "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha" + "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha", + new_callable=AsyncMock, ) def test_get_translations_success(self, mock_service): mock_service.return_value = V2SegmentTranslationsResponse( @@ -47,7 +103,7 @@ def test_get_translations_success(self, mock_service): has_more=True, ) - response = client.get("/v2/segments/parent-seg-1/translations") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations") assert response.status_code == 200 data = response.json() @@ -66,7 +122,8 @@ def test_get_translations_success(self, mock_service): ) @patch( - "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha" + "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha", + new_callable=AsyncMock, ) def test_get_translations_with_pagination(self, mock_service): mock_service.return_value = V2SegmentTranslationsResponse( @@ -80,7 +137,7 @@ def test_get_translations_with_pagination(self, mock_service): has_more=False, ) - response = client.get("/v2/segments/parent-seg-1/translations?skip=5&limit=3") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?skip=5&limit=3") assert response.status_code == 200 data = response.json() @@ -97,7 +154,8 @@ def test_get_translations_with_pagination(self, mock_service): class TestSegmentsV2CommentariesEndpoint: @patch( - "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha" + "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha", + new_callable=AsyncMock, ) def test_get_commentaries_success(self, mock_service): mock_service.return_value = V2SegmentCommentariesResponse( @@ -123,7 +181,7 @@ def test_get_commentaries_success(self, mock_service): has_more=False, ) - response = client.get("/v2/segments/parent-seg-1/commentaries") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries") assert response.status_code == 200 data = response.json() @@ -135,29 +193,30 @@ def test_get_commentaries_success(self, mock_service): class TestSegmentsV2ValidationErrors: def test_invalid_skip_negative_for_translations(self): - response = client.get("/v2/segments/parent-seg-1/translations?skip=-1") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?skip=-1") assert response.status_code == 422 def test_invalid_limit_zero_for_translations(self): - response = client.get("/v2/segments/parent-seg-1/translations?limit=0") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?limit=0") assert response.status_code == 422 def test_invalid_limit_too_large_for_translations(self): - response = client.get("/v2/segments/parent-seg-1/translations?limit=101") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?limit=101") assert response.status_code == 422 def test_invalid_skip_negative_for_commentaries(self): - response = client.get("/v2/segments/parent-seg-1/commentaries?skip=-1") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries?skip=-1") assert response.status_code == 422 def test_invalid_limit_zero_for_commentaries(self): - response = client.get("/v2/segments/parent-seg-1/commentaries?limit=0") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries?limit=0") assert response.status_code == 422 class TestSegmentsV2ErrorHandling: @patch( - "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha" + "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha", + new_callable=AsyncMock, ) def test_get_translations_not_found(self, mock_service): mock_service.side_effect = HTTPException( @@ -165,13 +224,14 @@ def test_get_translations_not_found(self, mock_service): detail="Segment with id 'missing-segment' not found", ) - response = client.get("/v2/segments/missing-segment/translations") + response = client.get(f"{SEGMENTS_ROOT}/missing-segment/translations") assert response.status_code == 404 assert "missing-segment" in response.json()["detail"] @patch( - "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha" + "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha", + new_callable=AsyncMock, ) def test_get_translations_upstream_error(self, mock_service): mock_service.side_effect = HTTPException( @@ -179,13 +239,14 @@ def test_get_translations_upstream_error(self, mock_service): detail="Failed to fetch related segments from upstream service", ) - response = client.get("/v2/segments/parent-seg-1/translations") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations") assert response.status_code == 502 assert "upstream" in response.json()["detail"].lower() @patch( - "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha" + "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha", + new_callable=AsyncMock, ) def test_get_commentaries_upstream_error(self, mock_service): mock_service.side_effect = HTTPException( @@ -193,7 +254,7 @@ def test_get_commentaries_upstream_error(self, mock_service): detail="Failed to fetch related segments from upstream service", ) - response = client.get("/v2/segments/parent-seg-1/commentaries") + response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries") assert response.status_code == 502 assert "upstream" in response.json()["detail"].lower() diff --git a/tests/segments/test_segments_service.py b/tests/segments/test_segments_service.py index 0b95f6bf8..a8b64faa5 100644 --- a/tests/segments/test_segments_service.py +++ b/tests/segments/test_segments_service.py @@ -1,495 +1,54 @@ -import uuid -from unittest.mock import AsyncMock, patch, MagicMock -from fastapi import HTTPException -import uuid import pytest -from pecha_api.texts.segments.segments_service import ( - create_new_segment, - get_translations_by_segment_id, - get_segment_details_by_id, - get_commentaries_by_segment_id, - get_info_by_segment_id, - get_root_text_mapping_by_segment_id, - remove_segments_by_text_id, - fetch_segments_by_text_id, - get_segments_details_by_ids, - update_segments_service -) -from pecha_api.texts.segments.segments_utils import SegmentUtils -from pecha_api.texts.segments.segments_response_models import ( - CreateSegmentRequest, - SegmentResponse, - CreateSegment, - ParentSegment, - SegmentTranslationsResponse, - SegmentTranslation, - SegmentDTO, - MappingResponse, - SegmentCommentariesResponse, - SegmentCommentry, - SegmentInfoResponse, - SegmentInfo, - RelatedText, - Resources, - SegmentRootMappingResponse, - SegmentRootMapping, - MappedSegmentDTO, - SegmentUpdateRequest, - SegmentUpdate -) +from unittest.mock import AsyncMock, patch +from fastapi import HTTPException +from pecha_api.texts.segments.segments_service import get_segments_details_by_ids +from pecha_api.texts.segments.segments_utils import SegmentUtils +from pecha_api.texts.segments.segments_response_models import SegmentDTO from pecha_api.texts.segments.segments_enum import SegmentType - - -from pecha_api.texts.texts_response_models import TextDTO - from pecha_api.error_contants import ErrorConstants -from pecha_api.cache.cache_enums import CacheType - -@pytest.mark.asyncio -async def test_get_translations_by_segment_id_success(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - segment = SegmentDTO( - id=segment_id, - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - content="To the buddhas: Vipaśyin,
Śikhin, Viśvabhū,
Krakucchanda, Kanakamuni,
and Kāśyapa,
And Śākyamuni—Gautama,
deity of all deities,
To the seven warrior-like buddhas, I pay homage!", - mapping=[], - type=SegmentType.SOURCE - ) - translations = [ - SegmentTranslation( - segment_id=f"efb26a06-f373-450b-ba57-e7a8d4dd5b64_{i}", - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - title = f"Title {i}", - source = f"source {i}", - language = "en", - content="To the buddhas: Vipaśyin,
Śikhin, Viśvabhū,
Krakucchanda, Kanakamuni,
and Kāśyapa,
And Śākyamuni—Gautama,
deity of all deities,
To the seven warrior-like buddhas, I pay homage!", - ) - for i in range(1, 4) - ] - with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock) as mock_segment, \ - patch("pecha_api.texts.segments.segments_service.SegmentUtils.filter_segment_mapping_by_type_or_text_id", new_callable=AsyncMock) as mock_filter, \ - patch("pecha_api.texts.segments.segments_service.get_related_mapped_segments", new_callable=AsyncMock) as mock_translations: - mock_segment.return_value = segment - mock_translations.return_value = translations - mock_filter.return_value = translations - - response = await get_translations_by_segment_id(segment_id=segment_id) - - assert response == SegmentTranslationsResponse( - parent_segment=ParentSegment( - segment_id=segment_id, - content="To the buddhas: Vipaśyin,
Śikhin, Viśvabhū,
Krakucchanda, Kanakamuni,
and Kāśyapa,
And Śākyamuni—Gautama,
deity of all deities,
To the seven warrior-like buddhas, I pay homage!" - ), - translations=translations - ) - - -@pytest.mark.asyncio -async def test_get_translations_by_segment_id_segment_not_found(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - - with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as excinfo: - await get_translations_by_segment_id(segment_id=segment_id) - assert excinfo.value.status_code == 404 - assert excinfo.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE -@pytest.mark.asyncio -async def test_create_new_segment(): - """ - Test case for the create_new_segment function from the segments_service file - """ - create_segment_request = CreateSegmentRequest( - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - segments=[ - CreateSegment( - content="content", - mapping=[], - type=SegmentType.SOURCE - ) - ] - ) - - with patch('pecha_api.texts.segments.segments_service.validate_user_exists', return_value=True), \ - patch('pecha_api.texts.segments.segments_service.TextUtils.validate_text_exists', new_callable=AsyncMock, return_value=True), \ - patch('pecha_api.texts.segments.segments_service.create_segment', new_callable=AsyncMock) as mock_create_segment: - mock_segment = type('Segment', (), { - 'id': uuid.UUID("efb26a06-f373-450b-ba57-e7a8d4dd5b64"), - 'pecha_segment_id': "pecha_efb26a06-f373-450b-ba57-e7a8d4dd5b64", - 'text_id': "efb26a06-f373-450b-ba57-e7a8d4dd5b64", - 'content': "content", - 'mapping': [], - 'type': SegmentType.SOURCE, - 'model_dump': lambda self: { - 'id': self.id, - 'pecha_segment_id': self.pecha_segment_id, - 'text_id': self.text_id, - 'content': self.content, - 'mapping': self.mapping, - 'type': self.type - } - })() - mock_create_segment.return_value = [mock_segment] - - response = await create_new_segment( - create_segment_request=create_segment_request, - token="admin" - ) - - expected_response = SegmentResponse( - segments=[ - SegmentDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - pecha_segment_id="pecha_efb26a06-f373-450b-ba57-e7a8d4dd5b64", - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - content="content", - mapping=[], - type=SegmentType.SOURCE - ) - ] - ) - assert response == expected_response - - -@pytest.mark.asyncio -async def test_create_new_segment_invalid_user(): - """ - Test case for the create_new_segment function fails due to admin - """ - create_segment_request = CreateSegmentRequest( - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - segments=[ - CreateSegment( - content="content", - mapping=[], - type=SegmentType.SOURCE - ) - ] - ) - - with patch('pecha_api.texts.segments.segments_service.validate_user_exists', return_value=False): - with pytest.raises(HTTPException) as exc_info: - await create_new_segment( - create_segment_request=create_segment_request, - token="no_admin" - ) - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE @pytest.mark.asyncio async def test_validate_segment_exists_success(): segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch('pecha_api.texts.segments.segments_utils.check_segment_exists', new_callable=AsyncMock) as mock_check: + with patch("pecha_api.texts.segments.segments_utils.check_segment_exists", new_callable=AsyncMock) as mock_check: mock_check.return_value = True result = await SegmentUtils.validate_segment_exists(segment_id) assert result is True + @pytest.mark.asyncio async def test_validate_segment_exists_not_found(): segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch('pecha_api.texts.segments.segments_utils.check_segment_exists', new_callable=AsyncMock) as mock_check: + with patch("pecha_api.texts.segments.segments_utils.check_segment_exists", new_callable=AsyncMock) as mock_check: mock_check.return_value = False with pytest.raises(HTTPException) as exc_info: await SegmentUtils.validate_segment_exists(segment_id) assert exc_info.value.status_code == 404 assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE + @pytest.mark.asyncio async def test_validate_segments_exists_success(): segment_ids = ["efb26a06-f373-450b-ba57-e7a8d4dd5b64", "efb26a06-f373-450b-ba57-e7a8d4dd5b65"] - with patch('pecha_api.texts.segments.segments_utils.check_all_segment_exists', new_callable=AsyncMock) as mock_check: + with patch("pecha_api.texts.segments.segments_utils.check_all_segment_exists", new_callable=AsyncMock) as mock_check: mock_check.return_value = True result = await SegmentUtils.validate_segments_exists(segment_ids) assert result is True + @pytest.mark.asyncio async def test_validate_segments_exists_not_found(): segment_ids = ["efb26a06-f373-450b-ba57-e7a8d4dd5b64", "efb26a06-f373-450b-ba57-e7a8d4dd5b65"] - with patch('pecha_api.texts.segments.segments_utils.check_all_segment_exists', new_callable=AsyncMock) as mock_check: + with patch("pecha_api.texts.segments.segments_utils.check_all_segment_exists", new_callable=AsyncMock) as mock_check: mock_check.return_value = False with pytest.raises(HTTPException) as exc_info: await SegmentUtils.validate_segments_exists(segment_ids) assert exc_info.value.status_code == 404 - # The error message includes the segment IDs in the format: "Segment not found {segment_ids}" assert ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE in exc_info.value.detail assert str(segment_ids) in exc_info.value.detail -@pytest.mark.asyncio -async def test_get_segment_details_by_id_without_text_details_success(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - mock_segment = type('Segment', (), { - 'id': segment_id, - 'pecha_segment_id': f"pecha_{segment_id}", - 'text_id': "text123", - 'content': "test content", - 'mapping': [], - 'type': SegmentType.SOURCE, - 'model_dump': lambda self: { - 'id': self.id, - 'pecha_segment_id': self.pecha_segment_id, - 'text_id': self.text_id, - 'content': self.content, - 'mapping': self.mapping, - 'type': self.type - } - })() - - with patch('pecha_api.texts.segments.segments_service.get_segment_by_id', new_callable=AsyncMock) as mock_get: - mock_get.return_value = mock_segment - response = await get_segment_details_by_id(segment_id) - assert isinstance(response, SegmentDTO) - assert str(response.id) == segment_id - assert response.text_id == "text123" - assert response.content == "test content" - assert response.mapping == [] - assert response.type == SegmentType.SOURCE - -@pytest.mark.asyncio -async def test_get_segment_details_by_id_not_found(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch('pecha_api.texts.segments.segments_service.get_segment_by_id', new_callable=AsyncMock) as mock_get: - mock_get.return_value = None - with pytest.raises(HTTPException) as exc_info: - await get_segment_details_by_id(segment_id) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_get_segment_details_by_id_with_text_details_success(): - segment_id = str(uuid.uuid4()) - text_id = str(uuid.uuid4()) - group_id = str(uuid.uuid4()) - mock_text_details = TextDTO( - id=text_id, - title="title", - language="en", - type="text", - group_id=group_id, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="admin", - categories=["category1", "category2"], - views=0 - ) - mock_segment = SegmentDTO( - id=segment_id, - text_id=text_id, - content="test content", - mapping=[], - type=SegmentType.SOURCE, - text=mock_text_details - ) - with patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock, return_value=mock_segment), \ - patch("pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details): - - response = await get_segment_details_by_id(segment_id=segment_id, text_details=True) - - assert response is not None - assert response.text_id == text_id - assert response.id == segment_id - assert response.text is not None - - - -@pytest.mark.asyncio -async def test_get_commentaries_by_segment_id_success(): - parent_segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - # repository segment object with id and content attributes - repo_parent_segment = type('Segment', (), { - 'id': parent_segment_id, - 'content': "parent_segment_content" - })() - commentaries = [ - SegmentDTO( - id=f"id_{i}", - text_id=f"text_id_{i}", - content=f"content_{i}", - mapping=[ - MappingResponse( - text_id="parent_text_id", - segments=[ - parent_segment_id - ] - ) - ], - type=SegmentType.SOURCE - ) - for i in range(1,6) - ] - filtered_commentaries = [ - SegmentCommentry( - text_id=f"text_id_{i}", - title=f"title_{i}", - segments=[ - MappedSegmentDTO( - segment_id=f"id_{i}", - content=f"content_{i}" - ) - ], - language="en", - count=1 - ) - for i in range(1,6) - ] - with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock) as mock_parent_segment, \ - patch("pecha_api.texts.segments.segments_service.get_related_mapped_segments", new_callable=AsyncMock) as mock_get_related_mapped_segment, \ - patch("pecha_api.texts.segments.segments_service.SegmentUtils.filter_segment_mapping_by_type_or_text_id", new_callable=AsyncMock) as mock_filtered_segment_mapping: - mock_parent_segment.return_value = repo_parent_segment - mock_get_related_mapped_segment.return_value = commentaries - mock_filtered_segment_mapping.return_value = filtered_commentaries - response = await get_commentaries_by_segment_id(segment_id=parent_segment_id) - assert isinstance(response, SegmentCommentariesResponse) - assert response.parent_segment.segment_id == parent_segment_id - assert response.parent_segment.content == "parent_segment_content" - assert response.commentaries[0].text_id == "text_id_1" - assert len(response.commentaries[0].segments) == 1 - assert response.commentaries[0].segments[0].segment_id == "id_1" - assert response.commentaries[0].segments[0].content == "content_1" - assert response.commentaries[0].language == "en" - assert response.commentaries[0].count == 1 - - -@pytest.mark.asyncio -async def test_get_commentaries_by_segment_id_not_found(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_commentaries_by_segment_id(segment_id=segment_id) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_get_infos_by_segment_id_success(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - text_id = "text_id_1" - mock_segment = type('Segment', (), { - 'id': segment_id, - 'text_id': text_id, - 'content': "test content", - 'mapping': [], - 'type': SegmentType.SOURCE - })() - mock_text_detail = TextDTO( - id=text_id, - title="title", - language="en", - type="text", - group_id="group_id", - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="admin", - categories=["category1"], - views=0 - ) - related_mapped_segments = [ - SegmentDTO( - id=f"id_{i}", - text_id=f"text_id_{i}", - content=f"content_{i}", - mapping=[], - type=SegmentType.SOURCE - ) - for i in range(1,6) - ] - - mock_segment_with_text = SegmentDTO( - id=segment_id, - text_id=text_id, - content="segment_content", - mapping=[], - type=SegmentType.SOURCE, - text=mock_text_detail - ) - - with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.segments.segments_service.get_segment_info_by_id_cache", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock, return_value=mock_segment), \ - patch("pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.segments.segments_service.get_related_mapped_segments", new_callable=AsyncMock) as mock_get_related_mapped_segment, \ - patch("pecha_api.texts.segments.segments_service.SegmentUtils.get_count_of_each_commentary_and_version", new_callable=AsyncMock, return_value={"version": 1, "commentary": 2}), \ - patch("pecha_api.texts.segments.segments_service.SegmentUtils.get_root_mapping_count", new_callable=AsyncMock, return_value=3), \ - patch("pecha_api.texts.segments.segments_service.set_segment_info_by_id_cache", new_callable=AsyncMock): - mock_get_related_mapped_segment.return_value = related_mapped_segments - - response = await get_info_by_segment_id(segment_id=segment_id) - assert isinstance(response, SegmentInfoResponse) - assert isinstance(response.segment_info, SegmentInfo) - assert isinstance(response.segment_info.related_text, RelatedText) - assert isinstance(response.segment_info.resources, Resources) - assert response.segment_info.segment_id == segment_id - assert response.segment_info.translations == 1 - assert response.segment_info.related_text.commentaries == 2 - assert response.segment_info.related_text.root_text == 3 - - -@pytest.mark.asyncio -async def test_get_infos_by_segment_id_invalid_segment_id(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_info_by_segment_id(segment_id=segment_id) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE - - -@pytest.mark.asyncio -async def test_get_root_text_mapping_by_segment_id_invalid_segment_id(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_root_text_mapping_by_segment_id(segment_id=segment_id) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE - - -@pytest.mark.asyncio -async def test_remove_segments_by_text_id_success(): - text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch("pecha_api.texts.segments.segments_service.delete_segments_by_text_id", new_callable=AsyncMock, return_value=True),\ - patch("pecha_api.texts.segments.segments_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True): - - response = await remove_segments_by_text_id(text_id=text_id) - - assert response is not None - -@pytest.mark.asyncio -async def test_remove_segments_by_text_id_invalid_text_id(): - text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - with patch("pecha_api.texts.segments.segments_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await remove_segments_by_text_id(text_id=text_id) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_fetch_segments_by_text_id_success(): - text_id = "text_id" - mock_segments = [ - SegmentDTO( - id=f"id_{i}", - text_id=f"{text_id}_{i}", - content=f"content_{i}", - mapping=[], - type=SegmentType.SOURCE - ) - for i in range(1,6) - ] - with patch("pecha_api.texts.segments.segments_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments): - response = await fetch_segments_by_text_id(text_id=text_id) - - assert response is not None - assert len(response) == 5 - assert response[0].id == "id_1" - assert response[0].text_id == f"{text_id}_1" - assert response[0].type == SegmentType.SOURCE - @pytest.mark.asyncio async def test_get_segments_details_by_ids_cache_hit(): @@ -533,7 +92,7 @@ async def test_get_segments_details_by_ids_cache_miss_sets_cache(): "pecha_api.texts.segments.segments_service.get_segments_details_by_ids_cache", new_callable=AsyncMock, return_value=None, - ) as mock_cache, patch( + ), patch( "pecha_api.texts.segments.segments_service.get_segments_by_ids", new_callable=AsyncMock, return_value=repo_result, @@ -543,244 +102,6 @@ async def test_get_segments_details_by_ids_cache_miss_sets_cache(): ) as mock_set: result = await get_segments_details_by_ids(segment_ids) assert result == repo_result - mock_cache.assert_awaited_once() mock_repo.assert_awaited_once_with(segment_ids=segment_ids) - # ensure cache set called with expected segment_ids assert mock_set.await_count == 1 - called_kwargs = mock_set.await_args.kwargs - assert called_kwargs["segment_ids"] == segment_ids - - -@pytest.mark.asyncio -async def test_get_info_by_segment_id_cache_hit(): - segment_id = "seg_1" - cached_response = SegmentInfoResponse( - segment_info=SegmentInfo( - segment_id=segment_id, - text_id="text_id_1", - translations=0, - related_text=RelatedText(commentaries=0, root_text=0), - resources=Resources(sheets=0), - ) - ) - - mock_text_detail = TextDTO( - id="text_id_1", - title="title", - language="en", - type="commentary", - group_id="group_id_1", - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - - mock_segment = type('Segment', (), { - 'id': segment_id, - 'text_id': "text_id_1", - 'content': "content", - 'mapping': [], - 'type': SegmentType.SOURCE - })() - - with patch( - "pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", - new_callable=AsyncMock, - return_value=True, - ), patch( - "pecha_api.texts.segments.segments_service.get_segment_info_by_id_cache", - new_callable=AsyncMock, - return_value=None, # Changed to None to test actual flow - ), patch( - "pecha_api.texts.segments.segments_service.get_segment_by_id", - new_callable=AsyncMock, - return_value=mock_segment, - ), patch( - "pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id", - new_callable=AsyncMock, - return_value=mock_text_detail, - ), patch( - "pecha_api.texts.segments.segments_service.get_related_mapped_segments", - new_callable=AsyncMock, - return_value=[], - ), patch( - "pecha_api.texts.segments.segments_service.SegmentUtils.get_count_of_each_commentary_and_version", - new_callable=AsyncMock, - ) as mock_count, patch( - "pecha_api.texts.segments.segments_service.SegmentUtils.get_root_mapping_count", - new_callable=AsyncMock, - ) as mock_root_count, patch( - "pecha_api.texts.segments.segments_service.set_segment_info_by_id_cache", - new_callable=AsyncMock, - ): - mock_count.return_value = {"version": 0, "commentary": 0} - mock_root_count.return_value = 0 - result = await get_info_by_segment_id(segment_id) - assert isinstance(result, SegmentInfoResponse) - assert result.segment_info.segment_id == segment_id - - -@pytest.mark.asyncio -async def test_get_info_by_segment_id_sets_cache_on_miss(): - segment_id = "seg_1" - text_id = "text_id_1" - mock_segment = type('Segment', (), { - 'id': segment_id, - 'text_id': text_id, - 'content': "test content", - 'mapping': [], - 'type': SegmentType.SOURCE - })() - mock_text_detail = TextDTO( - id=text_id, - title="title", - language="en", - type="text", - group_id="group_id", - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="admin", - categories=["category1"], - views=0 - ) - - with patch( - "pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", - new_callable=AsyncMock, - return_value=True, - ), patch( - "pecha_api.texts.segments.segments_service.get_segment_info_by_id_cache", - new_callable=AsyncMock, - return_value=None, - ), patch( - "pecha_api.texts.segments.segments_service.get_segment_by_id", - new_callable=AsyncMock, - return_value=mock_segment, - ), patch( - "pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id", - new_callable=AsyncMock, - return_value=mock_text_detail, - ), patch( - "pecha_api.texts.segments.segments_service.get_related_mapped_segments", - new_callable=AsyncMock, - return_value=[], - ), patch( - "pecha_api.texts.segments.segments_service.SegmentUtils.get_count_of_each_commentary_and_version", - new_callable=AsyncMock, - ) as mock_count, patch( - "pecha_api.texts.segments.segments_service.SegmentUtils.get_root_mapping_count", - new_callable=AsyncMock, - ) as mock_root_count, patch( - "pecha_api.texts.segments.segments_service.set_segment_info_by_id_cache", - new_callable=AsyncMock, - ) as mock_set: - mock_count.return_value = {"version": 0, "commentary": 0} - mock_root_count.return_value = 0 - result = await get_info_by_segment_id(segment_id) - assert isinstance(result, SegmentInfoResponse) - # ensure cache set was called with the built response - assert mock_set.await_count == 1 - called_kwargs = mock_set.await_args.kwargs - assert called_kwargs["segment_id"] == segment_id - assert called_kwargs["cache_type"] == CacheType.SEGMENT_INFO - - -@pytest.mark.asyncio -async def test_update_segments_service_success(): - """ - Test case for successful segment update with admin access - """ - segment_update_request = SegmentUpdateRequest( - pecha_text_id="pecha_text_123", - segments=[ - SegmentUpdate( - pecha_segment_id="pecha_segment_123", - content="Updated content" - ) - ] - ) - - mock_text = type('Text', (), { - 'id': "text_123", - 'pecha_text_id': "pecha_text_123", - 'title': "Test Text" - })() - - mock_updated_segment = type('Segment', (), { - 'id': "segment_id_123", - 'pecha_segment_id': "pecha_segment_123", - 'text_id': "text_123", - 'content': "Updated content", - 'mapping': [], - 'type': SegmentType.SOURCE - })() - - with patch('pecha_api.texts.segments.segments_service.verify_admin_access', return_value=True), \ - patch('pecha_api.texts.segments.segments_service.get_text_by_pecha_text_id', new_callable=AsyncMock, return_value=mock_text), \ - patch('pecha_api.texts.segments.segments_service.update_segment_by_id', new_callable=AsyncMock) as mock_update: - mock_update.return_value = mock_updated_segment - - result = await update_segments_service( - token="admin_token", - segment_update_request=segment_update_request - ) - - assert result is not None - mock_update.assert_awaited_once_with(segment_update_request=segment_update_request) - - -@pytest.mark.asyncio -async def test_update_segments_service_forbidden(): - """ - Test case for segment update with non-admin access - """ - segment_update_request = SegmentUpdateRequest( - pecha_text_id="pecha_text_123", - segments=[ - SegmentUpdate( - pecha_segment_id="pecha_segment_123", - content="Updated content" - ) - ] - ) - - with patch('pecha_api.texts.segments.segments_service.verify_admin_access', return_value=False): - with pytest.raises(HTTPException) as exc_info: - await update_segments_service( - token="user_token", - segment_update_request=segment_update_request - ) - assert exc_info.value.status_code == 403 - assert exc_info.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE - - -@pytest.mark.asyncio -async def test_update_segments_service_text_not_found(): - """ - Test case for segment update with invalid text - """ - segment_update_request = SegmentUpdateRequest( - pecha_text_id="invalid_pecha_text_id", - segments=[ - SegmentUpdate( - pecha_segment_id="pecha_segment_123", - content="Updated content" - ) - ] - ) - - with patch('pecha_api.texts.segments.segments_service.verify_admin_access', return_value=True), \ - patch('pecha_api.texts.segments.segments_service.get_text_by_pecha_text_id', new_callable=AsyncMock, return_value=None): - with pytest.raises(HTTPException) as exc_info: - await update_segments_service( - token="admin_token", - segment_update_request=segment_update_request - ) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE + assert mock_set.await_args.kwargs["segment_ids"] == segment_ids diff --git a/tests/segments/test_segments_utils.py b/tests/segments/test_segments_utils.py index a436458a8..64d5ea7d1 100644 --- a/tests/segments/test_segments_utils.py +++ b/tests/segments/test_segments_utils.py @@ -26,96 +26,6 @@ ) from pecha_api.texts.segments.segments_enum import SegmentType -from pecha_api.texts.groups.groups_response_models import GroupDTO - -@pytest.mark.asyncio -async def test_get_count_of_each_commentary_and_version_success(): - parent_text = TextDTO( - id="parent-text-id", - title="Parent Text", - language="bo", - group_id="group_id", - type="version", - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - text_details = { - "efb26a06-f373-450b-ba57-e7a8d4dd5b64": TextDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - title="title", - language="language", - group_id="group_id", - type="commentary", - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ), - "efb26a06-f373-450b-ba57-e7a8d4dd5b65": TextDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b65", - title="title", - language="language", - group_id="group_id", - type="version", - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ), - "efb26a06-f373-450b-ba57-e7a8d4dd5b66": TextDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b66", - title="title", - language="language", - group_id="group_id", - type="commentary", - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - } - list_of_segment_paramenter = [ - SegmentDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - content="content", - mapping=[], - type=SegmentType.SOURCE - ), - SegmentDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b65", - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b65", - content="content", - mapping=[], - type=SegmentType.SOURCE - ), - SegmentDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b66", - text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b66", - content="content", - mapping=[], - type=SegmentType.SOURCE - ) - ] - with patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_ids", new_callable=AsyncMock, return_value=text_details): - result = await SegmentUtils.get_count_of_each_commentary_and_version(list_of_segment_paramenter, parent_text=parent_text) - assert result["commentary"] == 2 - assert result["version"] == 1 - @pytest.mark.asyncio async def test_filter_segment_mapping_by_type_success(): """Test filtering segments by commentary type returns correct SegmentCommentry objects.""" @@ -211,155 +121,6 @@ async def test_filter_segment_mapping_by_type_success(): assert response[1].segments[0].segment_id == "efb26a06-f373-450b-ba57-e7a8d4dd5b66" assert response[1].segments[0].content == "commentary two content" assert response[1].count == 1 - -@pytest.mark.asyncio -async def test_get_root_mapping_count_success(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - group_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b65" - - # Create mock text details for each mapped text (non-commentary types to be counted) - mock_text_details_map = {} - for i in range(1, 6): - mock_text_details_map[f"text_id_{i}"] = TextDTO( - id=f"text_id_{i}", - title=f"title_{i}", - language="language", - type="version", # Changed to version so they won't be skipped - group_id=group_id, - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - - segment = SegmentDTO( - id=segment_id, - text_id=text_id, - content="content", - mapping=[ - MappingResponse( - text_id=f"text_id_{i}", - segments=[ - f"segment_id_{i}" - ] - ) - for i in range(1,6) - ], - type=SegmentType.SOURCE - ) - - text_details = TextDTO( - id=text_id, - title="title", - language="language", - type="commentary", - group_id=group_id, - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - - # Create mapped text details with different type/group to pass the filter - mapped_text_details = { - f"text_id_{i}": TextDTO( - id=f"text_id_{i}", - title=f"title_{i}", - language="language", - type="version", # Different type from parent - group_id="different_group_id", # Different group from parent - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - for i in range(1, 6) - } - - mock_group_details = GroupDTO( - id=group_id, - type="COMMENTARY" - ) - - # Mock to return different text details based on text_id - async def mock_get_text_details_by_id(text_id): - if text_id in mock_text_details_map: - return mock_text_details_map[text_id] - return text_details - - with patch("pecha_api.texts.segments.segments_utils.get_segment_by_id", new_callable=AsyncMock, return_value=segment), \ - patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_id", new_callable=AsyncMock, side_effect=mock_get_text_details_by_id), \ - patch("pecha_api.texts.segments.segments_utils.get_group_details", new_callable=AsyncMock, return_value=mock_group_details): - response = await SegmentUtils.get_root_mapping_count(segment_id=segment_id) - assert response == 5 - -@pytest.mark.asyncio -async def test_get_segment_root_mapping_details_success(): - parent_text = TextDTO( - id="parent-text-id", - title="Parent Title", - language="bo", - group_id="group_id", - type="version", - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - segments = [ - SegmentDTO( - id="efb26a06-f373-450b-ba57-e7a8d4dd5b64", - text_id="text_id_1", - content="content", - mapping=[ - MappingResponse( - text_id="text_id_1", - segments=[ - "segment_id_1" - ] - ) - ], - type=SegmentType.SOURCE - ) - ] - text_details = { - "text_id_1": TextDTO( - id="text_id_1", - title="title", - language="language", - group_id="different_group_id", - type="commentary", # Different type - is_published=True, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=["categories"], - views=0 - ) - } - with patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_ids", new_callable=AsyncMock, return_value=text_details): - response = await SegmentUtils.get_segment_root_mapping_details(segments=segments, parent_segment_text=parent_text) - assert isinstance(response[0], SegmentRootMapping) - assert response[0].text_id == "text_id_1" - assert response[0].title == "title" - assert response[0].language == "language" - assert len(response[0].segments) == 1 - assert response[0].segments[0].segment_id == "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - assert response[0].segments[0].content == "content" @pytest.mark.asyncio async def test_mapped_segment_content_for_table_of_content_without_version_id_success(): @@ -436,77 +197,6 @@ async def test_validate_segments_exists_invalid_uuid_in_list(): assert exc_info.value.status_code == 400 -@pytest.mark.asyncio -async def test_get_root_mapping_count_group_type_text_returns_zero(): - segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - text_id = "text-id-1" - - parent_text = TextDTO( - id="parent_text_id", - title="parent title", - language="en", - type="root_text", - group_id="parent_group_id", - is_published=True, - created_date="", - updated_date="", - published_date="", - published_by="", - categories=[], - views=0, - ) - - segment = SegmentDTO( - id=segment_id, - text_id=text_id, - content="content", - mapping=[ - MappingResponse(text_id="t1", segments=["s1", "s2"]), - ], - type=SegmentType.SOURCE, - ) - text_details = TextDTO( - id=text_id, - title="title", - language="en", - type="commentary", - group_id="g1", - is_published=True, - created_date="", - updated_date="", - published_date="", - published_by="", - categories=[], - views=0, - ) - - mapped_text_details = { - "t1": TextDTO( - id="t1", - title="title", - language="en", - type="version", - group_id="different_group", - is_published=True, - created_date="", - updated_date="", - published_date="", - published_by="", - categories=[], - views=0, - ) - } - - group_detail = GroupDTO(id="g1", type="text") - - with patch("pecha_api.texts.segments.segments_utils.get_segment_by_id", new_callable=AsyncMock, return_value=segment), \ - patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=text_details), \ - patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_ids", new_callable=AsyncMock, return_value=mapped_text_details), \ - patch("pecha_api.texts.segments.segments_utils.get_group_details", new_callable=AsyncMock, return_value=group_detail): - count = await SegmentUtils.get_root_mapping_count(segment_id=segment_id) - assert count == 0 - - @pytest.mark.asyncio async def test_filter_segment_mapping_by_type_commentary_merges_same_text_id(): """ @@ -857,168 +547,6 @@ async def test_validate_segments_exists_success(): "efb26a06-f373-450b-ba57-e7a8d4dd5b65", ]) is True - -def test_apply_bophono_with_tibetan_text(): - """Test apply_bophono with Tibetan text input.""" - from unittest.mock import Mock - - # Mock the tokenizer and its tokens - mock_token1 = type('Token', (), {'text': 'བཀྲ་ཤིས་'})() - mock_token2 = type('Token', (), {'text': 'བདེ་ལེགས་'})() - mock_tokens = [mock_token1, mock_token2] - - # Mock the converter - mock_converter = Mock() - mock_converter.get_api.side_effect = ['tra.shi', 'de.legs'] - - with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \ - patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class: - - # Setup mocks - mock_tokenizer = Mock() - mock_tokenizer.tokenize.return_value = mock_tokens - mock_tokenizer_class.return_value = mock_tokenizer - mock_converter_class.return_value = mock_converter - - # Test the method - result = SegmentUtils.apply_bophono("བཀྲ་ཤིས་བདེ་ལེགས་") - - # Verify the result - assert result == "tra.shi de.legs" - - # Verify method calls - mock_tokenizer.tokenize.assert_called_once_with("བཀྲ་ཤིས་བདེ་ལེགས་") - mock_converter_class.assert_called_once_with(schema="KVP", options={'aspirateLowTones': True}) - assert mock_converter.get_api.call_count == 2 - mock_converter.get_api.assert_any_call('བཀྲ་ཤིས་') - mock_converter.get_api.assert_any_call('བདེ་ལེགས་') - - -def test_apply_bophono_with_empty_string(): - """Test apply_bophono with empty string input.""" - from unittest.mock import Mock - - mock_tokenizer = Mock() - mock_tokenizer.tokenize.return_value = [] - mock_converter = Mock() - - with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \ - patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class: - - mock_tokenizer_class.return_value = mock_tokenizer - mock_converter_class.return_value = mock_converter - - result = SegmentUtils.apply_bophono("") - - assert result == "" - mock_tokenizer.tokenize.assert_called_once_with("") - mock_converter_class.assert_called_once_with(schema="KVP", options={'aspirateLowTones': True}) - mock_converter.get_api.assert_not_called() - - -def test_apply_bophono_with_single_word(): - """Test apply_bophono with single Tibetan word.""" - from unittest.mock import Mock - - mock_token = type('Token', (), {'text': 'ཨོཾ'})() - mock_tokens = [mock_token] - - mock_tokenizer = Mock() - mock_tokenizer.tokenize.return_value = mock_tokens - mock_converter = Mock() - mock_converter.get_api.return_value = 'om' - - with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \ - patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class: - - mock_tokenizer_class.return_value = mock_tokenizer - mock_converter_class.return_value = mock_converter - - result = SegmentUtils.apply_bophono("ཨོཾ") - - assert result == "om" - mock_tokenizer.tokenize.assert_called_once_with("ཨོཾ") - mock_converter.get_api.assert_called_once_with('ཨོཾ') - - -def test_apply_bophono_with_mixed_content(): - """Test apply_bophono with mixed Tibetan and punctuation.""" - from unittest.mock import Mock - - mock_token1 = type('Token', (), {'text': 'མཆོད་'})() - mock_token2 = type('Token', (), {'text': '།'})() - mock_token3 = type('Token', (), {'text': 'རྟེན་'})() - mock_tokens = [mock_token1, mock_token2, mock_token3] - - mock_tokenizer = Mock() - mock_tokenizer.tokenize.return_value = mock_tokens - mock_converter = Mock() - mock_converter.get_api.side_effect = ['chö', '།', 'ten'] - - with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \ - patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class: - - mock_tokenizer_class.return_value = mock_tokenizer - mock_converter_class.return_value = mock_converter - - result = SegmentUtils.apply_bophono("མཆོད་། རྟེན་") - - assert result == "chö ། ten" - mock_tokenizer.tokenize.assert_called_once_with("མཆོད་། རྟེན་") - assert mock_converter.get_api.call_count == 3 - mock_converter.get_api.assert_any_call('མཆོད་') - mock_converter.get_api.assert_any_call('།') - mock_converter.get_api.assert_any_call('རྟེན་') - - -def test_apply_bophono_converter_options(): - """Test that apply_bophono uses correct converter options.""" - from unittest.mock import Mock - - mock_token = type('Token', (), {'text': 'དཀར་'})() - mock_tokens = [mock_token] - - mock_tokenizer = Mock() - mock_tokenizer.tokenize.return_value = mock_tokens - mock_converter = Mock() - mock_converter.get_api.return_value = 'kar' - - with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \ - patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class: - - mock_tokenizer_class.return_value = mock_tokenizer - mock_converter_class.return_value = mock_converter - - SegmentUtils.apply_bophono("དཀར་") - - # Verify that the converter was initialized with correct options - mock_converter_class.assert_called_once_with( - schema="KVP", - options={'aspirateLowTones': True} - ) - - -def test_apply_bophono_with_whitespace_only(): - """Test apply_bophono with whitespace-only input.""" - from unittest.mock import Mock - - mock_tokenizer = Mock() - mock_tokenizer.tokenize.return_value = [] - mock_converter = Mock() - - with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \ - patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class: - - mock_tokenizer_class.return_value = mock_tokenizer - mock_converter_class.return_value = mock_converter - - result = SegmentUtils.apply_bophono(" ") - - assert result == "" - mock_tokenizer.tokenize.assert_called_once_with(" ") - mock_converter.get_api.assert_not_called() - - @pytest.mark.asyncio async def test_group_segment_content_by_text_id_with_table_of_contents(): """Test _group_segment_content_by_text_id groups and sorts segments correctly using TOC.""" diff --git a/tests/segments/test_segments_views.py b/tests/segments/test_segments_views.py deleted file mode 100644 index 35327fe25..000000000 --- a/tests/segments/test_segments_views.py +++ /dev/null @@ -1,341 +0,0 @@ -from unittest.mock import patch -from fastapi.testclient import TestClient -from fastapi import status, HTTPException -from uuid import uuid4 -from pecha_api.app import api - -from pecha_api.texts.segments.segments_response_models import ( - CreateSegmentRequest, - CreateSegment, - SegmentTranslationsResponse, - SegmentTranslation, - SegmentDTO, - SegmentUpdateRequest, - SegmentUpdate -) -from pecha_api.texts.texts_response_models import TextDTO - - -from pecha_api.error_contants import ErrorConstants -from pecha_api.texts.segments.segments_response_models import ParentSegment -from pecha_api.texts.segments.segments_enum import SegmentType - -client = TestClient(api) - -@patch("pecha_api.texts.segments.segments_views.get_segment_details_by_id") -def test_get_segment_without_text_details_success(mock_get_segment_details_by_id): - segment_id = str(uuid4()) - mock_response = SegmentDTO( - id=segment_id, - text_id="text_id", - content="content", - mapping=[], - type=SegmentType.SOURCE - ) - mock_get_segment_details_by_id.return_value = mock_response - response = client.get(f"/api/v1/segments/{segment_id}") - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["id"] == segment_id - assert data["text_id"] == "text_id" - assert data["content"] == "content" - assert data["text"] is None - -@patch("pecha_api.texts.segments.segments_views.get_segment_details_by_id") -def test_get_segment_with_text_details_success(mock_get_segment_details_by_id): - segment_id = str(uuid4()) - text_id = str(uuid4()) - mock_response = SegmentDTO( - id=segment_id, - text_id="text_id", - content="content", - mapping=[], - type=SegmentType.SOURCE, - text=TextDTO( - id=text_id, - title="title", - language="language", - type="type", - group_id="group_id", - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="admin", - categories=["category1", "category2"], - parent_id=None - ) - ) - mock_get_segment_details_by_id.return_value = mock_response - response = client.get(f"/api/v1/segments/{segment_id}?text_details=True") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["id"] == segment_id - assert data["text"] is not None - assert data["text"]["id"] == text_id - assert data["text"]["title"] == "title" - - -@patch("pecha_api.texts.segments.segments_views.get_translations_by_segment_id") -def test_get_translations_success(mock_get_translations): - # Mock data - segment_id = str(uuid4()) - mock_response = SegmentTranslationsResponse( - parent_segment=ParentSegment( - segment_id=segment_id, - content="Test segment content" - ), - translations=[ - SegmentTranslation( - segment_id="segment_id_1", - text_id="text1", - title='title', - source='source', - language='en', - content="Translation 1" - ), - SegmentTranslation( - segment_id="segment_id_2", - text_id="text2", - title='title2', - source='source2', - language='bo', - content="Translation 2" - ) - ] - ) - - # Mock the service function - mock_get_translations.return_value = mock_response - # Make request - response = client.get(f"/api/v1/segments/{segment_id}/translations") - - # Assert response - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["parent_segment"]["segment_id"] == segment_id - assert data["parent_segment"]["content"] == "Test segment content" - assert len(data["translations"]) == 2 - - # Verify first translation - assert data["translations"][0]["text_id"] == "text1" - assert data["translations"][0]["title"] == "title" - assert data["translations"][0]["source"] == "source" - assert data["translations"][0]["language"] == "en" - assert data["translations"][0]["content"] == "Translation 1" - - # Verify second translation - assert data["translations"][1]["text_id"] == "text2" - assert data["translations"][1]["language"] == "bo" - -@patch("pecha_api.texts.segments.segments_views.get_translations_by_segment_id") -def test_get_translations_with_pagination(mock_get_translations): - segment_id = str(uuid4()) - mock_response = SegmentTranslationsResponse( - parent_segment=ParentSegment( - segment_id=segment_id, - content="Test segment content" - ), - translations=[ - SegmentTranslation( - segment_id=f"segment_id_{i}", - text_id=f"text{i}", - title=f"title{i}", - source=f"source{i}", - language='en', - content=f"Translation {i}" - ) for i in range(5) - ] - ) - - mock_get_translations.return_value = mock_response - response = client.get(f"/api/v1/segments/{segment_id}/translations?skip=2&limit=3") - - assert response.status_code == status.HTTP_200_OK - mock_get_translations.assert_called_with(segment_id=segment_id) - -@patch("pecha_api.texts.segments.segments_views.get_translations_by_segment_id") -def test_get_translations_not_found(mock_get_translations): - segment_id = str(uuid4()) - mock_get_translations.side_effect = HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE - ) - - response = client.get(f"/api/v1/segments/{segment_id}/translations") - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE - -@patch("pecha_api.texts.segments.segments_views.create_new_segment") -def test_create_segment_success(mock_create_segment): - # Mock data - segment_id = str(uuid4()) - segment_request = CreateSegmentRequest( - text_id="text123", - segments=[ - CreateSegment( - content="New segment content", - mapping=[], - type=SegmentType.SOURCE - ) - ] - ) - mock_response = { - "segments": [ - { - "id": segment_id, - "text_id": segment_request.text_id, - "content": segment_request.segments[0].content, - "mapping": segment_request.segments[0].mapping, - "type": segment_request.segments[0].type - } - ] - - } - mock_create_segment.return_value = mock_response - - # Make request with auth token - response = client.post( - "/api/v1/segments", - json=segment_request.model_dump(mode="json"), - headers={"Authorization": "Bearer test_token"} - ) - - # Assert response - assert response.status_code == status.HTTP_201_CREATED - data = response.json() - - # Verify segment - assert data['segments'][0]["text_id"] == segment_request.text_id - assert data['segments'][0]["content"] == segment_request.segments[0].content - assert data['segments'][0]["id"] == segment_id - -def test_create_segment_unauthorized(): - segment_request = CreateSegmentRequest( - text_id="text123", - segments=[ - CreateSegment( - content="New segment content", - mapping=[], - type=SegmentType.SOURCE - ) - ] - ) - - # Make request without auth token - response = client.post( - "/api/v1/segments", - json=segment_request.model_dump(mode="json") - ) - - assert response.status_code == status.HTTP_403_FORBIDDEN - -@patch("pecha_api.texts.segments.segments_views.update_segments_service") -def test_update_segment_success(mock_update_segments_service): - # Mock data - segment_id = str(uuid4()) - segment_update_request = SegmentUpdateRequest( - pecha_text_id="pecha_text_123", - segments=[ - SegmentUpdate( - pecha_segment_id="pecha_segment_123", - content="Updated segment content" - ) - ] - ) - - mock_response = SegmentDTO( - id=segment_id, - pecha_segment_id="pecha_segment_123", - text_id="text123", - content="Updated segment content", - mapping=[], - type=SegmentType.SOURCE - ) - - mock_update_segments_service.return_value = mock_response - - # Make request with auth token - response = client.put( - "/api/v1/segments", - json=segment_update_request.model_dump(mode="json"), - headers={"Authorization": "Bearer admin_token"} - ) - - # Assert response - assert response.status_code == status.HTTP_200_OK - -def test_update_segment_unauthorized(): - segment_update_request = SegmentUpdateRequest( - pecha_text_id="pecha_text_123", - segments=[ - SegmentUpdate( - pecha_segment_id="pecha_segment_123", - content="Updated segment content" - ) - ] - ) - - # Make request without auth token - response = client.put( - "/api/v1/segments", - json=segment_update_request.model_dump(mode="json") - ) - - assert response.status_code == status.HTTP_403_FORBIDDEN - -@patch("pecha_api.texts.segments.segments_views.update_segments_service") -def test_update_segment_forbidden(mock_update_segments_service): - segment_update_request = SegmentUpdateRequest( - pecha_text_id="pecha_text_123", - segments=[ - SegmentUpdate( - pecha_segment_id="pecha_segment_123", - content="Updated segment content" - ) - ] - ) - - # Mock the service to raise a 403 Forbidden exception - mock_update_segments_service.side_effect = HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ErrorConstants.ADMIN_ERROR_MESSAGE - ) - - # Make request with non-admin token - response = client.put( - "/api/v1/segments", - json=segment_update_request.model_dump(mode="json"), - headers={"Authorization": "Bearer user_token"} - ) - - assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json()["detail"] == ErrorConstants.ADMIN_ERROR_MESSAGE - -@patch("pecha_api.texts.segments.segments_views.update_segments_service") -def test_update_segment_text_not_found(mock_update_segments_service): - segment_update_request = SegmentUpdateRequest( - pecha_text_id="invalid_pecha_text_id", - segments=[ - SegmentUpdate( - pecha_segment_id="pecha_segment_123", - content="Updated segment content" - ) - ] - ) - - # Mock the service to raise a 404 Not Found exception - mock_update_segments_service.side_effect = HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE - ) - - response = client.put( - "/api/v1/segments", - json=segment_update_request.model_dump(mode="json"), - headers={"Authorization": "Bearer admin_token"} - ) - - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == ErrorConstants.TEXT_NOT_FOUND_MESSAGE \ No newline at end of file diff --git a/tests/share/test_share_service.py b/tests/share/test_share_service.py index 23850fa20..22539f093 100644 --- a/tests/share/test_share_service.py +++ b/tests/share/test_share_service.py @@ -17,9 +17,11 @@ ShareRequest ) from pecha_api.share.share_enums import TextColor, BgColor -from pecha_api.texts.segments.segments_service import SegmentDTO +from pecha_api.texts.segments.segments_response_models import ( + V2SegmentResponse, + V2SegmentTextDetail, +) from pecha_api.texts.texts_response_models import TextDTO -from pecha_api.texts.segments.segments_enum import SegmentType @pytest.mark.asyncio @@ -90,38 +92,25 @@ async def test_generate_short_url_with_logo(): async def test_generate_short_url_for_segment_content_success(): share_request = ShareRequest( url="https://pecha.io/share/123", - segment_id="id_1", + segment_id="em5HPUEMRke2e0Qs2519J", + text_id="text_1", language="en", ) mock_short_url_response = ShortUrlResponse( shortUrl="https://pecha.io/share/123" ) - mock_segment_details = SegmentDTO( - id="id_1", - text_id="text_1", + mock_segment_details = V2SegmentResponse( + segment_id="em5HPUEMRke2e0Qs2519J", content="content_1", - mapping=[], - type=SegmentType.SOURCE - ) - mock_text_detail = TextDTO( - id="text_1", - title="title_1", - language="en", - type="version", - group_id="group_1", - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="user_1", - categories=[], - views=0 + text=V2SegmentTextDetail( + text_id="text_1", + title="title_1", + language="en", + ), ) with patch("pecha_api.share.share_service.get_short_url", new_callable=AsyncMock, return_value=mock_short_url_response), \ - patch("pecha_api.share.share_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.share.share_service.get_segment_details_by_id", new_callable=AsyncMock, return_value=mock_segment_details), \ - patch("pecha_api.share.share_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ + patch("pecha_api.share.share_service.get_openpecha_segment_details_by_id", new_callable=AsyncMock, return_value=mock_segment_details), \ patch("pecha_api.share.share_service.generate_segment_image") as mock_generate_image: response = await generate_short_url(share_request=share_request) @@ -192,40 +181,33 @@ def test_generate_logo_image(): async def test_generate_segment_content_image_with_segment(): share_request = ShareRequest( text_id="text_1", - segment_id="seg_1", + segment_id="em5HPUEMRke2e0Qs2519J", language="en", text_color=TextColor.BLACK, bg_color=BgColor.DEFAULT ) - mock_text_detail = TextDTO( - id="text_1", - title="Test Title", - language="en", - type="version", - group_id="group_1", - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="user_1", - categories=[], - views=0 - ) - mock_segment = SegmentDTO( - id="seg_1", - text_id="text_1", + mock_segment = V2SegmentResponse( + segment_id="em5HPUEMRke2e0Qs2519J", content="Test segment content", - mapping=[], - type=SegmentType.SOURCE + text=V2SegmentTextDetail( + text_id="text_1", + title="Test Title", + language="en", + ), ) - with patch("pecha_api.share.share_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.share.share_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock), \ - patch("pecha_api.share.share_service.get_segment_details_by_id", new_callable=AsyncMock, return_value=mock_segment), \ - patch("pecha_api.share.share_service.generate_segment_image") as mock_generate_image: - + with patch( + "pecha_api.share.share_service.get_openpecha_segment_details_by_id", + new_callable=AsyncMock, + return_value=mock_segment, + ) as mock_get_segment, patch( + "pecha_api.share.share_service.generate_segment_image" + ) as mock_generate_image: await _generate_segment_content_image_(share_request) + mock_get_segment.assert_awaited_once_with( + segment_id="em5HPUEMRke2e0Qs2519J", + ) mock_generate_image.assert_called_once_with( text="Test segment content", ref_str="Test Title", diff --git a/tests/sheets/test_sheets_service.py b/tests/sheets/test_sheets_service.py deleted file mode 100644 index 50b92b3f8..000000000 --- a/tests/sheets/test_sheets_service.py +++ /dev/null @@ -1,2176 +0,0 @@ -import io -import pytest -import uuid -from unittest.mock import patch, MagicMock, AsyncMock -from fastapi import UploadFile, HTTPException, status -from pecha_api.error_contants import ErrorConstants -from pecha_api.image_utils import ImageUtils - -from pecha_api.sheets.sheets_response_models import ( - CreateSheetRequest, - Source, - SheetIdResponse, - SheetDetailDTO, - SheetSection, - SheetDTO, - SheetDTOResponse, - SheetDTO, - Publisher -) -from pecha_api.texts.segments.segments_enum import SegmentType -from pecha_api.texts.segments.segments_response_models import ( - CreateSegmentRequest, - SegmentResponse, - SegmentDTO -) -from pecha_api.texts.texts_response_models import ( - TableOfContent, - TableOfContentType, - Section, - TextSegment, - TextDTO -) -from pecha_api.texts.texts_enums import TextType -from pecha_api.users.user_response_models import UserInfoResponse -from pecha_api.texts.groups.groups_response_models import GroupDTO -from pecha_api.sheets.sheets_service import ( - create_new_sheet, - update_sheet_by_id, - get_sheet_by_id, - delete_sheet_by_id, - fetch_sheets, - _generate_sheet_summary_, - _strip_html_tags_, - _generate_sheet_detail_dto_, - upload_sheet_image_request, - _fetch_user_sheets_, - _generate_sheet_dto_response_, - _create_publisher_object_, - _generate_sheet_section_, - _get_all_segment_ids_in_table_of_content_, - _update_text_details_, - _generate_and_upload_sheet_table_of_content, - _process_and_upload_sheet_segments, - _generate_sheet_table_of_content_, - _generate_segment_dictionary_, - _generate_segment_creation_request_payload_, - _create_sheet_text_, - _create_sheet_group_, - clean_text -) - -from pecha_api.texts.groups.groups_enums import GroupType -from pecha_api.users.users_models import Users -from pecha_api.sheets.sheets_enum import SortBy, SortOrder -import hashlib - -from pecha_api.texts.segments.segments_response_models import ( - SegmentDTO, - SegmentResponse -) -from pecha_api.users.user_response_models import UserInfoResponse -from pecha_api.texts.texts_enums import TextType - - -def test_validate_and_compress_image_success(): - file_content = io.BytesIO(b"fake_image_data") - file = UploadFile(filename="test.jpg", file=file_content) - - with patch("pecha_api.image_utils.get_int", side_effect=[5, 75]), \ - patch("PIL.Image.open") as mock_open: - mock_image = MagicMock() - mock_image.mode = 'RGB' # Set the mode to RGB - mock_open.return_value = mock_image - mock_image.save = MagicMock() - - image_utils = ImageUtils() - compressed_image = image_utils.validate_and_compress_image(file=file, content_type="image/jpeg") - assert isinstance(compressed_image, io.BytesIO) - mock_image.save.assert_called_once_with(compressed_image, format="JPEG", quality=75) - - -def test_validate_and_compress_image_invalid_file_type(): - file_content = io.BytesIO(b"fake_image_data") - file = UploadFile(filename="test.txt", file=file_content) - try: - image_utils = ImageUtils() - image_utils.validate_and_compress_image(file=file, content_type="text/plain") - except HTTPException as e: - assert e.status_code == status.HTTP_400_BAD_REQUEST - assert e.detail == 'Only image files are allowed' - - -def test_validate_and_compress_image_file_too_large(): - file_content = io.BytesIO(b"fake_image_data" * 1024 * 1024 * 6) # 6 MB - file = UploadFile(filename="test.jpg", file=file_content) - - with patch("pecha_api.config.get_int", return_value=5), \ - pytest.raises(HTTPException) as exc_info: - image_utils = ImageUtils() - image_utils.validate_and_compress_image(file=file, content_type="image/jpeg") - assert exc_info.value.status_code == 413 - assert exc_info.value.detail == "File size exceeds 1MB limit" - - -def test_validate_and_compress_image_processing_failure(): - file_content = io.BytesIO(b"fake_image_data") - file = UploadFile(filename="test.jpg", file=file_content) - - with patch("pecha_api.config.get_int", side_effect=[5, 75]), \ - pytest.raises(HTTPException) as exc_info: - image_utils = ImageUtils() - image_utils.validate_and_compress_image(file=file, content_type="image/jpeg") - assert exc_info.value.status_code == 400 - assert exc_info.value.detail == "Failed to process the image" - - -@pytest.mark.asyncio -async def test_create_new_sheet_success(): - mock_source = [ - Source( - position=1, - type=SegmentType.SOURCE, - content="source_segment_id" - ), - Source( - position=2, - type=SegmentType.CONTENT, - content="content" - ), - Source( - position=3, - type=SegmentType.IMAGE, - content="image_url" - ) - ] - mock_create_sheet_request = CreateSheetRequest( - title="sheet_title", - source=mock_source, - is_published=True - ) - mock_token = "valid_token" - mock_user_details = type("User",(), { - "email": "test_user@gmail.com", - }) - mock_group_response = type("GroupDTO", (), { - "id": "group_id", - "type": "sheet" - }) - mock_text_response = type("TextDTO", (), { - "id": "text_id", - "title": "sheet_title", - "group_id": "group_id", - "type": "sheet", - "is_published": True, - "created_date": "2021-01-01", - "updated_date": "2021-01-01", - "published_date": "2021-01-01", - "published_by": "test_user" - }) - mock_segment_response = SegmentResponse( - segments=[ - SegmentDTO( - id="segment_id_1", - text_id="text_id", - content="source_segment_id", - type=SegmentType.SOURCE - ), - SegmentDTO( - id="segment_id_2", - text_id="text_id", - content="content", - type=SegmentType.CONTENT - ), - SegmentDTO( - id="segment_id_3", - text_id="text_id", - content="image_url", - type=SegmentType.IMAGE - ) - ] - ) - mock_table_of_content_response = TableOfContent( - id="table_of_content_id", - text_id="text_id", - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_id", - section_number=1, - segments=[ - TextSegment( - segment_id="segment_id_1", - segment_number=1 - ), - TextSegment( - segment_id="segment_id_2", - segment_number=2 - ), - TextSegment( - segment_id="segment_id_3", - segment_number=3 - ) - ] - ) - ] - ) - with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, return_value=mock_group_response), \ - patch("pecha_api.sheets.sheets_service.create_new_text", new_callable=AsyncMock, return_value=mock_text_response), \ - patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user_details), \ - patch("pecha_api.sheets.sheets_service.create_new_segment", new_callable=AsyncMock, return_value=mock_segment_response), \ - patch("pecha_api.sheets.sheets_service.create_table_of_content", new_callable=AsyncMock, return_value=mock_table_of_content_response): - - response = await create_new_sheet( - create_sheet_request=mock_create_sheet_request, - token=mock_token - ) - - assert response is not None - assert isinstance(response, SheetIdResponse) - assert response.sheet_id == "text_id" - -@pytest.mark.asyncio -async def test_create_sheet_invalid_token(): - mock_create_sheet_request = CreateSheetRequest( - title="sheet_title", - source=[] - ) - with pytest.raises(HTTPException) as exc_info: - await create_new_sheet( - create_sheet_request=mock_create_sheet_request, - token="invalid_token" - ) - assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE - -@pytest.mark.asyncio -async def test_update_sheet_success(): - # Use proper UUID format for sheet_id - sheet_id = str(uuid.uuid4()) - - mock_source = [ - Source( - position=1, - type=SegmentType.SOURCE, - content="source_segment_id" - ), - Source( - position=2, - type=SegmentType.CONTENT, - content="content" - ), - Source( - position=3, - type=SegmentType.IMAGE, - content="image_url" - ) - ] - mock_segment_response = SegmentResponse( - segments=[ - SegmentDTO( - id="segment_id_1", - text_id=sheet_id, - content="source_segment_id", - type=SegmentType.SOURCE - ), - SegmentDTO( - id="segment_id_2", - text_id=sheet_id, - content="content", - type=SegmentType.CONTENT - ), - SegmentDTO( - id="segment_id_3", - text_id=sheet_id, - content="image_url", - type=SegmentType.IMAGE - ) - ] - ) - mock_update_sheet_request = CreateSheetRequest( - title="updated_sheet_title", - source=mock_source, - is_published=True - ) - - # Add mock for TextUtils.get_text_details_by_id which is called at the end of update_sheet_by_id - mock_text_details = TextDTO( - id=sheet_id, - title="updated_sheet_title", - language="language", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test_user", - categories=[], - views=10 - ) - - with patch("pecha_api.sheets.sheets_service.remove_segments_by_text_id", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \ - patch("pecha_api.sheets.sheets_service.remove_table_of_content_by_text_id", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.delete_text_details_by_id_cache", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.delete_table_of_content_by_sheet_id_cache", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.delete_segments_details_by_ids_cache", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.update_text_details", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.create_new_segment", new_callable=AsyncMock, return_value=mock_segment_response), \ - patch("pecha_api.sheets.sheets_service.create_table_of_content", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details), \ - patch("pecha_api.sheets.sheets_service.update_text_details_cache", new_callable=AsyncMock): - - response = await update_sheet_by_id( - sheet_id=sheet_id, - update_sheet_request=mock_update_sheet_request, - token="valid_token" - ) - - assert response is not None - assert isinstance(response, SheetIdResponse) - assert response.sheet_id == sheet_id - -@pytest.mark.asyncio -async def test_update_sheet_invalid_token(): - # Use proper UUID format for sheet_id - sheet_id = str(uuid.uuid4()) - - mock_update_sheet_request = CreateSheetRequest( - title="updated_sheet_title", - source=[] - ) - with pytest.raises(HTTPException) as exc_info: - await update_sheet_by_id( - sheet_id=sheet_id, - update_sheet_request=mock_update_sheet_request, - token="invalid_token" - ) - assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE - -@pytest.mark.asyncio -async def test_get_sheet_by_id_success(): - sheet_id = "text_id" - mock_sheet_details = TextDTO( - id=sheet_id, - title="sheet_title", - language="language", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test_user", - categories=[], - views=10 - ) - mock_user_details = UserInfoResponse( - firstname="firstname", - lastname="lastname", - username="username", - email="test_user@gmail.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - mock_table_of_content_response = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_id", - section_number=1, - segments=[ - TextSegment( - segment_id="segment_id_1", - segment_number=1 - ), - TextSegment( - segment_id="segment_id_2", - segment_number=2 - ) - ] - ) - ] - ) - - segment_dict = { - "segment_id_1": SegmentDTO( - id="segment_id_1", - text_id="text_id", - content="content", - type=SegmentType.CONTENT, - ), - "segment_id_2": SegmentDTO( - id="segment_id_2", - text_id="text_id", - content="image_key", - type=SegmentType.IMAGE, - ) - } - with patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_user_details), \ - patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value=segment_dict), \ - patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content_response), \ - patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", new_callable=MagicMock, return_value="image_url"), \ - patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_sheet_details): - - response = await get_sheet_by_id(sheet_id=sheet_id, skip=0, limit=10) - - assert response is not None - assert isinstance(response, SheetDetailDTO) - assert response.id == sheet_id - assert response.sheet_title == "sheet_title" - assert response.publisher is not None - assert isinstance(response.content, SheetSection) - assert response.content is not None - assert response.content.segments is not None - assert len(response.content.segments) == 2 - assert response.content.segments[0].type == SegmentType.CONTENT - assert response.content.segments[0].content == "content" - assert response.content.segments[0].key is None - assert response.content.segments[1].type == SegmentType.IMAGE - assert response.content.segments[1].content == "image_url" - assert response.content.segments[1].key == "image_key" - -@pytest.mark.asyncio -async def test_get_sheet_by_id_invalid_sheet_id(): - sheet_id="invalid_sheet_id" - with patch("pecha_api.texts.texts_utils.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_sheet_by_id(sheet_id=sheet_id, skip=0, limit=10) - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_get_sheet_by_id_table_of_content_not_found(): - sheet_id = "text_id" - mock_sheet_details = TextDTO( - id=sheet_id, - title="sheet_title", - language="language", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test_user", - categories=[], - views=10 - ) - mock_user_details = UserInfoResponse( - firstname="firstname", - lastname="lastname", - username="username", - email="test_user@gmail.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - - segment_dict = { - "segment_id_1": SegmentDTO( - id="segment_id_1", - text_id="text_id", - content="content", - type=SegmentType.CONTENT, - ), - "segment_id_2": SegmentDTO( - id="segment_id_2", - text_id="text_id", - content="content", - type=SegmentType.IMAGE, - ) - } - with patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_sheet_details), \ - patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_user_details), \ - patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value=segment_dict), \ - patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None): - - with pytest.raises(HTTPException) as exc_info: - await get_sheet_by_id(sheet_id=sheet_id, skip=0, limit=10) - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND - assert exc_info.value.detail == ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_delete_sheet_success(): - mock_sheet_id = "text_id" - mock_token = "valid_token" - text_details = TextDTO( - id="sheet_id", - title="sheet_title", - language="language", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test_user@gmail.com", - categories=[], - views=10 - ) - mock_user_details = UserInfoResponse( - firstname="firstname", - lastname="lastname", - username="username", - email="test_user@gmail.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \ - patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=text_details), \ - patch("pecha_api.sheets.sheets_service.get_user_info", new_callable=AsyncMock, return_value=mock_user_details), \ - patch("pecha_api.sheets.sheets_service.delete_group_by_group_id", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.remove_segments_by_text_id", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.remove_table_of_content_by_text_id", new_callable=AsyncMock), \ - patch("pecha_api.sheets.sheets_service.delete_text_by_text_id", new_callable=AsyncMock): - - response = await delete_sheet_by_id( - sheet_id=mock_sheet_id, - token=mock_token - ) - - assert response is None - -@pytest.mark.asyncio -async def test_delete_sheet_invalid_token(): - with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=False): - with pytest.raises(HTTPException) as exc_info: - await delete_sheet_by_id( - sheet_id="text_id", - token="invalid_token" - ) - assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE - -@pytest.mark.asyncio -async def test_delete_sheet_invalid_sheet_id(): - with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \ - patch("pecha_api.texts.texts_utils.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await delete_sheet_by_id( - sheet_id="invalid_sheet_id", - token="valid_token" - ) - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - - - - - - - -# Test cases for fetch_sheets function -@pytest.mark.asyncio -async def test_fetch_sheets_community_page_all_published(): - mock_user = UserInfoResponse( - firstname="firstname", - lastname="lastname", - username="username", - email="test_user@gmail.com", - educations=[], - avatar_url="avatar_url", - social_profiles=[], - followers=0, - following=0, - ) - mock_sheets = _generate_mock_sheets_response_() - - with patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets), \ - patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"), \ - patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_user), \ - patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5): - - result = await fetch_sheets( - token="valid_token", - language="en", - email=None, - skip=0, - limit=10 - ) - - assert result is not None - assert isinstance(result, SheetDTOResponse) - assert result.sheets is not None - assert len(result.sheets) == 5 - assert isinstance(result.sheets[0], SheetDTO) - assert result.sheets[0].id == "sheet_id_1" - assert result.sheets[-1].id == "sheet_id_5" - -@pytest.mark.asyncio -async def test_fetch_sheets_user_own_sheets(): - #Test fetch_sheets for user's own sheets - show both published and unpublished - mock_user_details = type("User", (), { - "email": "mock_user@gmail.com", - }) - mock_publisher_details = UserInfoResponse( - firstname="firstname", - lastname="lastname", - username="username", - email="mock_user@gmail.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - mock_sheets = _generate_mock_sheets_response_() - for i in range(len(mock_sheets)): - mock_sheets[i].published_by = "mock_user@gmail.com" - - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user_details), \ - patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"), \ - patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_publisher_details), \ - patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets), \ - patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5): - - result = await fetch_sheets( - token="valid_token", - email="mock_user@gmail.com", - skip=0, - limit=10 - ) - - assert result is not None - assert isinstance(result, SheetDTOResponse) - assert result.sheets is not None - assert len(result.sheets) == 5 - assert isinstance(result.sheets[0], SheetDTO) - assert result.sheets[0].id == "sheet_id_1" - assert result.sheets[-1].id == "sheet_id_5" - -@pytest.mark.asyncio -async def test_fetch_sheets_user_viewing_other_users_sheets_status_logged_in(): - #Test fetch_sheets for user viewing other user's sheets - show only published - mock_user_details = type("User", (), { - "email": "mock_user@gmail.com", - }) - mock_publisher_details = UserInfoResponse( - firstname="firstname", - lastname="lastname", - username="username", - email="other_user@gmail.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - mock_sheets = _generate_mock_sheets_response_() - for i in range(len(mock_sheets)): - mock_sheets[i].published_by = "other_user@gmail.com" - - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user_details), \ - patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"), \ - patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_publisher_details), \ - patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets), \ - patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5): - - result = await fetch_sheets( - token="valid_token", - language="en", - email="test_user@gmail.com", - skip=0, - limit=10 - ) - - assert result is not None - assert isinstance(result, SheetDTOResponse) - assert result.sheets is not None - assert len(result.sheets) == 5 - assert isinstance(result.sheets[0], SheetDTO) - assert result.sheets[0].id == "sheet_id_1" - assert result.sheets[-1].id == "sheet_id_5" - -@pytest.mark.asyncio -async def test_fetch_sheets_invalid_token(): - #Test fetch_sheets with invalid token - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", side_effect=HTTPException(status_code=401, detail=ErrorConstants.TOKEN_ERROR_MESSAGE)), \ - patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"): - with pytest.raises(HTTPException) as exc_info: - await fetch_sheets( - token="invalid_token", - language="en", - email="test@example.com", - skip=0, - limit=10 - ) - - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE - - -def _generate_mock_sheets_response_(): - return [ - TextDTO( - id=f"sheet_id_{i}", - title="Test Sheet", - language="en", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test_user", - categories=[], - views=10 - ) - for i in range(1,6) - ] - -# Test cases for _generate_sheet_summary_ function -@pytest.mark.asyncio -async def test_generate_sheet_summary_success(): - #Test normal case with content segments# - sheet_id = "test_sheet_id" - - # Mock table of content with sections and segments - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="content_segment_1", segment_number=1), - TextSegment(segment_id="content_segment_2", segment_number=2), - TextSegment(segment_id="image_segment_1", segment_number=3) - ] - ) - ] - ) - - # Mock the first content segment that will be returned - mock_content_segment = SegmentDTO( - id="content_segment_1", - text_id=sheet_id, - content="This is the first content segment.", - type=SegmentType.CONTENT - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment): - - result = await _generate_sheet_summary_(sheet_id) - - expected = "This is the first content segment." - assert result == expected - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_with_html_tags(): - #Test HTML tag stripping functionality - sheet_id = "test_sheet_id" - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="content_segment_1", segment_number=1) - ] - ) - ] - ) - - mock_content_segment = SegmentDTO( - id="content_segment_1", - text_id=sheet_id, - content="

This is bold text with italics and links.

", - type=SegmentType.CONTENT - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment): - - result = await _generate_sheet_summary_(sheet_id) - - expected = "This is bold text with italics and links." - assert result == expected - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_exceeds_max_words(): - #Test content that exceeds max_words limit - sheet_id = "test_sheet_id" - - # Create content with more than 30 words (default max_words) - long_content = " ".join([f"word{i}" for i in range(1, 41)]) # 40 words - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="content_segment_1", segment_number=1) - ] - ) - ] - ) - - mock_content_segment = SegmentDTO( - id="content_segment_1", - text_id=sheet_id, - content=long_content, - type=SegmentType.CONTENT - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment): - - result = await _generate_sheet_summary_(sheet_id) - - # Should contain exactly 30 words plus "..." - words = result.split() - assert len(words) == 30 # 30 words (the last word has "..." appended) - assert result.endswith("...") - assert "word1" in result - assert "word30" in result - assert "word31" not in result.replace("...", "") - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_custom_max_words(): - #Test with limited content (less than default 30 words) - sheet_id = "test_sheet_id" - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="content_segment_1", segment_number=1) - ] - ) - ] - ) - - mock_content_segment = SegmentDTO( - id="content_segment_1", - text_id=sheet_id, - content="This is a test content with only a few words.", - type=SegmentType.CONTENT - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment): - - result = await _generate_sheet_summary_(sheet_id) - - expected = "This is a test content with only a few words." - assert result == expected - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_no_table_of_content(): - #Test when table of content doesn't exist - sheet_id = "test_sheet_id" - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None): - - result = await _generate_sheet_summary_(sheet_id) - - assert result == "" - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_empty_sections(): - #Test when table of content exists but has no sections - sheet_id = "test_sheet_id" - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[] - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content): - - result = await _generate_sheet_summary_(sheet_id) - - assert result == "" - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_no_segments(): - #Test when no segments exist in the table of content - sheet_id = "test_sheet_id" - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[] - ) - ] - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content): - - result = await _generate_sheet_summary_(sheet_id) - - assert result == "" - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_no_content_segments(): - #Test when no content type segments exist (only images, sources, etc.) - sheet_id = "test_sheet_id" - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="image_segment_1", segment_number=1), - TextSegment(segment_id="source_segment_1", segment_number=2) - ] - ) - ] - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=None): - - result = await _generate_sheet_summary_(sheet_id) - - assert result == "" - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_empty_content(): - #Test when content segments exist but have empty content - sheet_id = "test_sheet_id" - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="content_segment_1", segment_number=1), - TextSegment(segment_id="content_segment_2", segment_number=2) - ] - ) - ] - ) - - mock_content_segment = SegmentDTO( - id="content_segment_1", - text_id=sheet_id, - content="", # Empty string instead of None - type=SegmentType.CONTENT - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment): - - result = await _generate_sheet_summary_(sheet_id) - - assert result == "" - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_error_handling(): - #Test error handling - should return empty string when exceptions occurd - sheet_id = "test_sheet_id" - - # Simulate an exception in get_table_of_content_by_sheet_id - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, side_effect=Exception("Database error")): - - result = await _generate_sheet_summary_(sheet_id) - - assert result == "" - - -@pytest.mark.asyncio -async def test_generate_sheet_summary_segment_not_found(): - #Test when segment IDs in table of content don't exist in segments dictionaryx1 - sheet_id = "test_sheet_id" - - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="missing_segment", segment_number=1) - ] - ) - ] - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=None): - - result = await _generate_sheet_summary_(sheet_id) - - assert result == "" - -# Test cases for _strip_html_tags_ function -def test_strip_html_tags_simple_tags(): - #Test stripping simple HTML tags# - html_content = "

Hello world!

" - result = _strip_html_tags_(html_content) - assert result == "Hello world!" - -def test_strip_html_tags_complex_html(): - #Test stripping complex HTML with attributes# - html_content = '

Test link content

' - result = _strip_html_tags_(html_content) - assert result == "Test link content" - -def test_strip_html_tags_no_html(): - #Test with plain text (no HTML tags)# - plain_text = "This is plain text without HTML" - result = _strip_html_tags_(plain_text) - assert result == "This is plain text without HTML" - -def test_strip_html_tags_empty_string(): - #Test with empty string# - result = _strip_html_tags_("") - assert result == "" - -def test_strip_html_tags_whitespace_handling(): - #Test HTML stripping with whitespace# - html_content = "

Content with spaces

" - result = _strip_html_tags_(html_content) - assert result == "Content with spaces" - -def test_strip_html_tags_nested_tags(): - #Test stripping nested HTML tags# - html_content = "

Nested content

" - result = _strip_html_tags_(html_content) - assert result == "Nested content" - - -# Test cases for upload_sheet_image_request function -def test_upload_sheet_image_request_with_sheet_id(): - #Test uploading sheet image with sheet ID# - file_content = io.BytesIO(b"fake_image_data") - file = UploadFile(filename="test.jpg", file=file_content) - # Create a mock file with content_type since the property is read-only - mock_file = MagicMock(spec=UploadFile) - mock_file.filename = "test.jpg" - mock_file.file = file_content - mock_file.content_type = "image/jpeg" - - sheet_id = "test_sheet_id" - - with patch("pecha_api.sheets.sheets_service.ImageUtils.validate_and_compress_image") as mock_validate, \ - patch("pecha_api.sheets.sheets_service.upload_bytes", return_value="test_upload_key") as mock_upload, \ - patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", return_value="https://test-url.com") as mock_presigned, \ - patch("pecha_api.sheets.sheets_service.get", return_value="test-bucket"): - - mock_validate.return_value = io.BytesIO(b"compressed_image_data") - - result = upload_sheet_image_request(sheet_id=sheet_id, file=mock_file) - - assert result.url == "https://test-url.com" - assert result.key == "test_upload_key" - mock_validate.assert_called_once() - mock_upload.assert_called_once() - mock_presigned.assert_called_once() - -def test_upload_sheet_image_request_without_sheet_id(): - #Test uploading sheet image without sheet ID# - file_content = io.BytesIO(b"fake_image_data") - file = UploadFile(filename="test.jpg", file=file_content) - # Create a mock file with content_type since the property is read-only - mock_file = MagicMock(spec=UploadFile) - mock_file.filename = "test.jpg" - mock_file.file = file_content - mock_file.content_type = "image/jpeg" - - with patch("pecha_api.sheets.sheets_service.ImageUtils.validate_and_compress_image") as mock_validate, \ - patch("pecha_api.sheets.sheets_service.upload_bytes", return_value="test_upload_key") as mock_upload, \ - patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", return_value="https://test-url.com") as mock_presigned, \ - patch("pecha_api.sheets.sheets_service.get", return_value="test-bucket"): - - mock_validate.return_value = io.BytesIO(b"compressed_image_data") - - result = upload_sheet_image_request(sheet_id=None, file=mock_file) - - assert result.url == "https://test-url.com" - assert result.key == "test_upload_key" - - -# Test cases for _fetch_user_sheets_ function -@pytest.mark.asyncio -async def test_fetch_user_sheets_none_token(): - #Test _fetch_user_sheets_ with 'None' token# - mock_sheets = _generate_mock_sheets_response_() - - with patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets): - result = await _fetch_user_sheets_( - token="None", - email="test@example.com", - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.DESC, - skip=0, - limit=10 - ) - - assert result == mock_sheets - -@pytest.mark.asyncio -async def test_fetch_user_sheets_own_sheets(): - #Test _fetch_user_sheets_ for user's own sheets# - mock_user = Users(email="test@example.com") - mock_sheets = _generate_mock_sheets_response_() - - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user), \ - patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets): - - result = await _fetch_user_sheets_( - token="valid_token", - email="test@example.com", - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.DESC, - skip=0, - limit=10 - ) - - assert result == mock_sheets - -@pytest.mark.asyncio -async def test_fetch_user_sheets_other_user(): - #Test _fetch_user_sheets_ for other user's sheets# - mock_user = Users(email="current@example.com") - mock_sheets = _generate_mock_sheets_response_() - - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user), \ - patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets): - - result = await _fetch_user_sheets_( - token="valid_token", - email="other@example.com", - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.DESC, - skip=0, - limit=10 - ) - - assert result == mock_sheets - - -# Test cases for _generate_sheet_dto_response_ function -@pytest.mark.asyncio -async def test_generate_sheet_dto_response(): - #Test _generate_sheet_dto_response_# - mock_sheets = _generate_mock_sheets_response_() - mock_user = UserInfoResponse( - firstname="Test", - lastname="User", - username="testuser", - email="test@example.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - - with patch("pecha_api.sheets.sheets_service._generate_sheet_summary_", new_callable=AsyncMock, return_value="Test summary"), \ - patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="2 days ago"), \ - patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user): - - result = await _generate_sheet_dto_response_(sheets=mock_sheets, total=5, skip=0, limit=10) - - assert isinstance(result, SheetDTOResponse) - assert len(result.sheets) == 5 - assert result.skip == 0 - assert result.limit == 10 - assert result.total == 5 - assert all(sheet.summary == "Test summary" for sheet in result.sheets) - - -# Test cases for _create_publisher_object_ function -def test_create_publisher_object(): - #Test _create_publisher_object_# - mock_user = UserInfoResponse( - firstname="John", - lastname="Doe", - username="johndoe", - email="john@example.com", - avatar_url="https://avatar.url", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - - with patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user): - result = _create_publisher_object_(published_by="john@example.com") - - assert isinstance(result, Publisher) - assert result.name == "John Doe" - assert result.username == "johndoe" - assert result.email == "john@example.com" - assert result.avatar_url == "https://avatar.url" - -def test_create_publisher_object_no_name(): - #Test _create_publisher_object_ when user has no first/last name# - mock_user = UserInfoResponse( - firstname="", - lastname="", - username="johndoe", - email="john@example.com", - avatar_url="https://avatar.url", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - - with patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user): - result = _create_publisher_object_(published_by="john@example.com") - - assert result.name == "johndoe" # Falls back to username - - -# Test cases for _generate_sheet_section_ function -@pytest.mark.asyncio -async def test_generate_sheet_section(): - #Test _generate_sheet_section_# - segments = [ - TextSegment(segment_id="content_segment", segment_number=1), - TextSegment(segment_id="image_segment", segment_number=2), - TextSegment(segment_id="source_segment", segment_number=3) - ] - - segments_dict = { - "content_segment": SegmentDTO( - id="content_segment", - text_id="test_text_id", - content="Test content", - type=SegmentType.CONTENT - ), - "image_segment": SegmentDTO( - id="image_segment", - text_id="test_text_id", - content="image_key", - type=SegmentType.IMAGE - ), - "source_segment": SegmentDTO( - id="source_segment", - text_id="source_text_id", - content="source_content", - type=SegmentType.SOURCE - ) - } - - mock_source_text = TextDTO( - id="source_text_id", - title="Source Text", - language="en", - group_id="group_id", - type=TextType.VERSION, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test@example.com", - categories=[], - views=0 - ) - - with patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_source_text), \ - patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", return_value="https://presigned-image-url.com"), \ - patch("pecha_api.sheets.sheets_service.get", return_value="test-bucket"): - - result = await _generate_sheet_section_(segments=segments, segments_dict=segments_dict) - - assert isinstance(result, SheetSection) - assert result.section_number == 1 - assert len(result.segments) == 3 - - # Check content segment - content_seg = next(seg for seg in result.segments if seg.segment_id == "content_segment") - assert content_seg.content == "Test content" - assert content_seg.type == SegmentType.CONTENT - - # Check image segment - image_seg = next(seg for seg in result.segments if seg.segment_id == "image_segment") - assert image_seg.content == "https://presigned-image-url.com" - assert image_seg.type == SegmentType.IMAGE - - # Check source segment - source_seg = next(seg for seg in result.segments if seg.segment_id == "source_segment") - assert source_seg.content == "source_content" - assert source_seg.type == SegmentType.SOURCE - assert source_seg.language == "en" - assert source_seg.text_title == "Source Text" - - - -def test_get_all_segment_ids_empty_sections(): - #Test _get_all_segment_ids_in_table_of_content_ with empty sections# - result = _get_all_segment_ids_in_table_of_content_(sheet_sections=[]) - assert result == [] - - -# Test cases for _update_text_details_ function -@pytest.mark.asyncio -async def test_update_text_details(): - #Test _update_text_details_# - sheet_id = "test_sheet_id" - update_request = CreateSheetRequest( - title="Updated Title", - source=[], - is_published=True - ) - - with patch("pecha_api.sheets.sheets_service.update_text_details", new_callable=AsyncMock) as mock_update: - await _update_text_details_(sheet_id=sheet_id, update_sheet_request=update_request) - - mock_update.assert_called_once() - call_args = mock_update.call_args - assert call_args[1]['text_id'] == sheet_id - assert call_args[1]['update_text_request'].title == "Updated Title" - assert call_args[1]['update_text_request'].is_published == True - - -# Test cases for _generate_and_upload_sheet_table_of_content function -@pytest.mark.asyncio -async def test_generate_and_upload_sheet_table_of_content(): - #Test _generate_and_upload_sheet_table_of_content# - create_request = CreateSheetRequest( - title="Test Sheet", - source=[ - Source(position=1, type=SegmentType.CONTENT, content="test content") - ], - is_published=True - ) - text_id = "test_text_id" - segment_dict = {hashlib.sha256("test content".encode()).hexdigest(): "segment_id_123"} - token = "valid_token" - - mock_table_of_content = TableOfContent( - text_id=text_id, - type=TableOfContentType.SHEET, - sections=[] - ) - - with patch("pecha_api.sheets.sheets_service.create_table_of_content", new_callable=AsyncMock, return_value=mock_table_of_content) as mock_create: - result = await _generate_and_upload_sheet_table_of_content( - create_sheet_request=create_request, - text_id=text_id, - segment_dict=segment_dict, - token=token - ) - - mock_create.assert_called_once() - # The function returns the generated table of content, not the mock - assert isinstance(result, TableOfContent) - assert result.text_id == text_id - assert len(result.sections) == 1 - assert len(result.sections[0].segments) == 1 - - -# Test cases for _process_and_upload_sheet_segments function -@pytest.mark.asyncio -async def test_process_and_upload_sheet_segments(): - #Test _process_and_upload_sheet_segments# - create_request = CreateSheetRequest( - title="Test Sheet", - source=[ - Source(position=1, type=SegmentType.CONTENT, content="test content"), - Source(position=2, type=SegmentType.IMAGE, content="image_url") - ], - is_published=True - ) - text_id = "test_text_id" - token = "valid_token" - - mock_segments = SegmentResponse( - segments=[ - SegmentDTO( - id="seg_1", - text_id=text_id, - content="test content", - type=SegmentType.CONTENT - ), - SegmentDTO( - id="seg_2", - text_id=text_id, - content="image_url", - type=SegmentType.IMAGE - ) - ] - ) - - with patch("pecha_api.sheets.sheets_service.create_new_segment", new_callable=AsyncMock, return_value=mock_segments): - result = await _process_and_upload_sheet_segments( - create_sheet_request=create_request, - text_id=text_id, - token=token - ) - - assert isinstance(result, dict) - # Should contain hash keys for non-source segments - assert len(result) >= 0 # Could be empty if all segments are SOURCE type - - -# Test cases for _generate_sheet_table_of_content_ function -def test_generate_sheet_table_of_content(): - #Test _generate_sheet_table_of_content_# - create_request = CreateSheetRequest( - title="Test Sheet", - source=[ - Source(position=1, type=SegmentType.SOURCE, content="source_segment_id"), - Source(position=2, type=SegmentType.CONTENT, content="test content"), - Source(position=3, type=SegmentType.IMAGE, content="image_url") - ], - is_published=True - ) - text_id = "test_text_id" - segment_dict = { - hashlib.sha256("test content".encode()).hexdigest(): "content_segment_id", - hashlib.sha256("image_url".encode()).hexdigest(): "image_segment_id" - } - - result = _generate_sheet_table_of_content_( - create_sheet_request=create_request, - text_id=text_id, - segment_dict=segment_dict - ) - - assert isinstance(result, TableOfContent) - assert result.text_id == text_id - assert len(result.sections) == 1 - assert result.sections[0].section_number == 1 - assert len(result.sections[0].segments) == 3 - - -# Test cases for _generate_segment_dictionary_ function -def test_generate_segment_dictionary(): - #Test _generate_segment_dictionary_# - segments_response = SegmentResponse( - segments=[ - SegmentDTO( - id="seg_1", - text_id="text_id", - content="content 1", - type=SegmentType.CONTENT - ), - SegmentDTO( - id="seg_2", - text_id="text_id", - content="source_id", - type=SegmentType.SOURCE - ), - SegmentDTO( - id="seg_3", - text_id="text_id", - content="image_url", - type=SegmentType.IMAGE - ) - ] - ) - - result = _generate_segment_dictionary_(new_segments=segments_response) - - # Should contain entries for non-SOURCE segments only - expected_content_hash = hashlib.sha256("content 1".encode()).hexdigest() - expected_image_hash = hashlib.sha256("image_url".encode()).hexdigest() - - assert expected_content_hash in result - assert expected_image_hash in result - assert result[expected_content_hash] == "seg_1" - assert result[expected_image_hash] == "seg_3" - - # SOURCE segments should not be in dictionary - source_hash = hashlib.sha256("source_id".encode()).hexdigest() - assert source_hash not in result - - -# Test cases for _generate_segment_creation_request_payload_ function -def test_generate_segment_creation_request_payload(): - #Test _generate_segment_creation_request_payload_# - create_request = CreateSheetRequest( - title="Test Sheet", - source=[ - Source(position=1, type=SegmentType.SOURCE, content="source_segment_id"), - Source(position=2, type=SegmentType.CONTENT, content="test content"), - Source(position=3, type=SegmentType.IMAGE, content="image_url") - ], - is_published=True - ) - text_id = "test_text_id" - - result = _generate_segment_creation_request_payload_( - create_sheet_request=create_request, - text_id=text_id - ) - - assert isinstance(result, CreateSegmentRequest) - assert result.text_id == text_id - # Should exclude SOURCE type segments - assert len(result.segments) == 2 - assert all(seg.type != SegmentType.SOURCE for seg in result.segments) - - -# Test cases for _create_sheet_text_ function -@pytest.mark.asyncio -async def test_create_sheet_text(): - #Test _create_sheet_text_# - title = "Test Sheet" - token = "valid_token" - group_id = "test_group_id" - - mock_user = Users(email="test@example.com") - mock_text = TextDTO( - id="new_text_id", - title=title, - group_id=group_id, - type=TextType.SHEET, - language=None, - is_published=False, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test@example.com", - categories=[], - views=0 - ) - - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user), \ - patch("pecha_api.sheets.sheets_service.create_new_text", new_callable=AsyncMock, return_value=mock_text): - - result = await _create_sheet_text_(title=title, token=token, group_id=group_id) - - assert result == "new_text_id" - - -@pytest.mark.asyncio -async def test_create_sheet_text_title_required_empty(): - #Test _create_sheet_text_ should fail when title is empty - mock_user = type("User", (), {"email": "test@example.com"}) - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user): - with pytest.raises(HTTPException) as exc_info: - await _create_sheet_text_(title="", token="valid_token", group_id="group_id") - assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST - assert exc_info.value.detail == ErrorConstants.SHEET_TITLE_REQUIRED_MESSAGE - - -@pytest.mark.asyncio -async def test_create_sheet_text_title_required_none(): - #Test _create_sheet_text_ should fail when title is None - mock_user = type("User", (), {"email": "test@example.com"}) - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user): - with pytest.raises(HTTPException) as exc_info: - await _create_sheet_text_(title=None, token="valid_token", group_id="group_id") # type: ignore[arg-type] - assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST - assert exc_info.value.detail == ErrorConstants.SHEET_TITLE_REQUIRED_MESSAGE - - -# Test cases for _create_sheet_group_ function -@pytest.mark.asyncio -async def test_create_sheet_group(): - #Test _create_sheet_group_# - token = "valid_token" - - mock_group = GroupDTO( - id="new_group_id", - type=GroupType.SHEET - ) - - with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, return_value=mock_group): - result = await _create_sheet_group_(token=token) - - assert result == "new_group_id" - - -# Test cases for _generate_sheet_detail_dto_ function (the one with views parameter) -@pytest.mark.asyncio -async def test_generate_sheet_detail_dto_with_views(): - #Test _generate_sheet_detail_dto_ with views parameter# - sheet_details = TextDTO( - id="sheet_id", - title="Test Sheet", - language="en", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test@example.com", - categories=[], - views=100 - ) - - user_details = UserInfoResponse( - firstname="Test", - lastname="User", - username="testuser", - email="test@example.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - - sheet_sections = [ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ] - ) - ] - - segments_dict = { - "seg_1": SegmentDTO( - id="seg_1", - text_id="sheet_id", - content="test content", - type=SegmentType.CONTENT - ) - } - - with patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value=segments_dict): - # Note: Testing the version with views parameter by calling it directly - # Since there appear to be two versions of this function in the code - result = await _generate_sheet_detail_dto_( - sheet_details=sheet_details, - user_details=user_details, - sheet_sections=sheet_sections, - skip=0, - limit=10 - ) - - assert isinstance(result, SheetDetailDTO) - assert result.id == "sheet_id" - assert result.sheet_title == "Test Sheet" - assert result.views == 0 # The imported function version uses default value - assert result.publisher.name == "Test User" - assert result.total == 1 - -# Test cases for clean_text function -def test_clean_text_success(): - #Test clean_text with content under max words# - content = "This is a test content with less than thirty words total." - result = clean_text(content) - assert result == "This is a test content with less than thirty words total." - -def test_clean_text_exceeds_max_words(): - #Test clean_text with content exceeding max words# - content = " ".join([f"word{i}" for i in range(1, 35)]) # 34 words - result = clean_text(content) - words = result.split() - assert len(words) == 30 - assert result.endswith("...") - -def test_clean_text_with_html(): - #Test clean_text with HTML content# - content = "

This is bold text with italics.

" - result = clean_text(content) - assert result == "This is bold text with italics." - -def test_clean_text_empty_content(): - #Test clean_text with empty content# - result = clean_text("") - assert result == "" - -def test_clean_text_whitespace_only(): - #Test clean_text with whitespace only content# - result = clean_text(" ") - assert result == "" - - -# Test cases for _delete_sheet_table_of_content_cache_ function -@pytest.mark.asyncio -async def test_delete_sheet_table_of_content_cache_success(): - #Test _delete_sheet_table_of_content_cache_ with valid table of content# - sheet_id = "test_sheet_id" - mock_table_of_content = TableOfContent( - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[] - ) - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.sheets.sheets_service.delete_table_of_content_by_sheet_id_cache", new_callable=AsyncMock) as mock_delete_cache: - - from pecha_api.sheets.sheets_service import _delete_sheet_table_of_content_cache_ - result = await _delete_sheet_table_of_content_cache_(sheet_id=sheet_id) - - assert result == mock_table_of_content - mock_delete_cache.assert_called_once() - -@pytest.mark.asyncio -async def test_delete_sheet_table_of_content_cache_no_content(): - #Test _delete_sheet_table_of_content_cache_ with no table of content# - sheet_id = "test_sheet_id" - - with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.sheets.sheets_service.delete_table_of_content_by_sheet_id_cache", new_callable=AsyncMock) as mock_delete_cache: - - from pecha_api.sheets.sheets_service import _delete_sheet_table_of_content_cache_ - result = await _delete_sheet_table_of_content_cache_(sheet_id=sheet_id) - - assert result is None - mock_delete_cache.assert_not_called() - - -# Test cases for _delete_sheet_segments_cache_ function -@pytest.mark.asyncio -async def test_delete_sheet_segments_cache_with_content(): - #Test _delete_sheet_segments_cache_ with valid table of content# - mock_table_of_content = TableOfContent( - text_id="test_id", - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2) - ] - ) - ] - ) - - with patch("pecha_api.sheets.sheets_service.delete_segments_details_by_ids_cache", new_callable=AsyncMock) as mock_delete_cache: - from pecha_api.sheets.sheets_service import _delete_sheet_segments_cache_ - await _delete_sheet_segments_cache_(sheet_table_of_content=mock_table_of_content) - - mock_delete_cache.assert_called_once() - call_args = mock_delete_cache.call_args - assert "seg_1" in call_args[1]['segment_ids'] - assert "seg_2" in call_args[1]['segment_ids'] - -@pytest.mark.asyncio -async def test_delete_sheet_segments_cache_no_content(): - #Test _delete_sheet_segments_cache_ with None table of content# - with patch("pecha_api.sheets.sheets_service.delete_segments_details_by_ids_cache", new_callable=AsyncMock) as mock_delete_cache: - from pecha_api.sheets.sheets_service import _delete_sheet_segments_cache_ - await _delete_sheet_segments_cache_(sheet_table_of_content=None) - - mock_delete_cache.assert_called_once() - call_args = mock_delete_cache.call_args - assert call_args[1]['segment_ids'] == [] - - -# Test cases for delete_sheet_by_id unauthorized access -@pytest.mark.asyncio -async def test_delete_sheet_forbidden_access(): - #Test delete_sheet_by_id when user tries to delete another user's sheet# - sheet_id = "test_sheet_id" - token = "valid_token" - - text_details = TextDTO( - id=sheet_id, - title="sheet_title", - language="language", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="owner@gmail.com", # Different user - categories=[], - views=10 - ) - - current_user_details = UserInfoResponse( - firstname="Current", - lastname="User", - username="currentuser", - email="current@gmail.com", # Different email - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - - with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \ - patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=text_details), \ - patch("pecha_api.sheets.sheets_service.get_user_info", new_callable=AsyncMock, return_value=current_user_details): - - with pytest.raises(HTTPException) as exc_info: - await delete_sheet_by_id(sheet_id=sheet_id, token=token) - - assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN - assert exc_info.value.detail == ErrorConstants.FORBIDDEN_ERROR_MESSAGE - - -# Test cases for _get_sheet_first_content_details_by_type_ function -@pytest.mark.asyncio -async def test_get_sheet_first_content_details_by_type_success(): - #Test _get_sheet_first_content_details_by_type_ with valid segment IDs# - segment_ids = ["seg_1", "seg_2", "seg_3"] - mock_segment = SegmentDTO( - id="seg_1", - text_id="text_id", - content="first content", - type=SegmentType.CONTENT - ) - - with patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_segment): - from pecha_api.sheets.sheets_service import _get_sheet_first_content_details_by_type_ - result = await _get_sheet_first_content_details_by_type_( - segment_ids=segment_ids, - segment_type=SegmentType.CONTENT - ) - - assert result == mock_segment - -@pytest.mark.asyncio -async def test_get_sheet_first_content_details_by_type_no_result(): - #Test _get_sheet_first_content_details_by_type_ with no matching segments# - segment_ids = ["seg_1", "seg_2", "seg_3"] - - with patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=None): - from pecha_api.sheets.sheets_service import _get_sheet_first_content_details_by_type_ - result = await _get_sheet_first_content_details_by_type_( - segment_ids=segment_ids, - segment_type=SegmentType.CONTENT - ) - - assert result is None - - -# Test cases for _get_all_segment_ids_in_table_of_content_ edge cases -def test_get_all_segment_ids_empty_segments(): - #Test _get_all_segment_ids_in_table_of_content_ with sections containing no segments# - sheet_sections = [ - Section( - id="section_1", - section_number=1, - segments=[] - ), - Section( - id="section_2", - section_number=2, - segments=[] - ) - ] - - result = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections) - assert result == [] - -def test_get_all_segment_ids_multiple_sections(): - #Test _get_all_segment_ids_in_table_of_content_ with multiple sections# - sheet_sections = [ - Section( - id="section_1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2) - ] - ), - Section( - id="section_2", - section_number=2, - segments=[ - TextSegment(segment_id="seg_3", segment_number=1), - TextSegment(segment_id="seg_4", segment_number=2) - ] - ) - ] - - result = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections) - assert result == ["seg_1", "seg_2", "seg_3", "seg_4"] - - -# Test cases for fetch_sheets with specific parameters -@pytest.mark.asyncio -async def test_fetch_sheets_with_sort_parameters(): - #Test fetch_sheets with sorting parameters# - mock_user = UserInfoResponse( - firstname="Test", - lastname="User", - username="testuser", - email="test@example.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - mock_sheets = _generate_mock_sheets_response_() - - with patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheet, \ - patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="1 day ago"), \ - patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user), \ - patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5): - - result = await fetch_sheets( - token="valid_token", - language="en", - email=None, - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.ASC, - skip=5, - limit=20 - ) - - # Verify get_sheet was called with correct parameters - mock_get_sheet.assert_called_once_with( - is_published=True, - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.ASC, - skip=5, - limit=20 - ) - - assert isinstance(result, SheetDTOResponse) - assert result.skip == 5 - assert result.limit == 20 - - -# Additional test for upload_sheet_image_request error handling -def test_upload_sheet_image_request_validation_error(): - #Test upload_sheet_image_request when image validation fails# - file_content = io.BytesIO(b"invalid_image_data") - mock_file = MagicMock(spec=UploadFile) - mock_file.filename = "test.jpg" - mock_file.file = file_content - mock_file.content_type = "image/jpeg" - - with patch("pecha_api.sheets.sheets_service.ImageUtils.validate_and_compress_image", side_effect=HTTPException(status_code=400, detail="Invalid image")): - with pytest.raises(HTTPException) as exc_info: - upload_sheet_image_request(sheet_id="test_id", file=mock_file) - - assert exc_info.value.status_code == 400 - assert exc_info.value.detail == "Invalid image" - - -# Test case for _generate_sheet_detail_dto_ with different publisher name scenarios -@pytest.mark.asyncio -async def test_generate_sheet_detail_dto_empty_publisher_name(): - #Test _generate_sheet_detail_dto_ with empty publisher first/last name# - sheet_details = TextDTO( - id="sheet_id", - title="Test Sheet", - language="en", - group_id="group_id", - type=TextType.SHEET, - is_published=True, - created_date="2021-01-01", - updated_date="2021-01-01", - published_date="2021-01-01", - published_by="test@example.com", - categories=[], - views=50 - ) - - user_details = UserInfoResponse( - firstname="", # Empty first name - lastname="", # Empty last name - username="testuser", - email="test@example.com", - educations=[], - followers=0, - following=0, - social_profiles=[] - ) - - sheet_sections = [] - - with patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value={}): - result = await _generate_sheet_detail_dto_( - sheet_details=sheet_details, - user_details=user_details, - sheet_sections=sheet_sections, - skip=0, - limit=10 - ) - - assert isinstance(result, SheetDetailDTO) - assert result.id == "sheet_id" - assert result.publisher.name == " " # Empty first and last name results in a space character - - -# Test case for _generate_sheet_section_ with SOURCE segment error handling -@pytest.mark.asyncio -async def test_generate_sheet_section_source_segment_error(): - #Test _generate_sheet_section_ when source text details cannot be retrieved# - segments = [ - TextSegment(segment_id="source_segment", segment_number=1) - ] - - segments_dict = { - "source_segment": SegmentDTO( - id="source_segment", - text_id="invalid_text_id", - content="source_content", - type=SegmentType.SOURCE - ) - } - - with patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, side_effect=HTTPException(status_code=404, detail="Text not found")): - - with pytest.raises(HTTPException): - await _generate_sheet_section_(segments=segments, segments_dict=segments_dict) - - -# Test case for create_new_sheet with invalid token in internal function -@pytest.mark.asyncio -async def test_create_new_sheet_group_creation_error(): - #Test create_new_sheet when group creation fails# - mock_create_sheet_request = CreateSheetRequest( - title="Test Sheet", - source=[], - is_published=True - ) - - with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, side_effect=HTTPException(status_code=401, detail="Unauthorized")): - - with pytest.raises(HTTPException) as exc_info: - await create_new_sheet( - create_sheet_request=mock_create_sheet_request, - token="invalid_token" - ) - - assert exc_info.value.status_code == 401 - - -# Test case for update_sheet_by_id cache deletion error handling -@pytest.mark.asyncio -async def test_update_sheet_cache_deletion_error(): - #Test update_sheet_by_id when cache deletion fails - should propagate the error# - sheet_id = str(uuid.uuid4()) - mock_update_request = CreateSheetRequest( - title="Updated Title", - source=[ - Source(position=1, type=SegmentType.CONTENT, content="test content") - ], - is_published=True - ) - - with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \ - patch("pecha_api.sheets.sheets_service.delete_text_details_by_id_cache", new_callable=AsyncMock, side_effect=Exception("Cache error")): - - # Should propagate the cache error since there's no error handling in the function - with pytest.raises(Exception) as exc_info: - await update_sheet_by_id( - sheet_id=sheet_id, - update_sheet_request=mock_update_request, - token="valid_token" - ) - - assert str(exc_info.value) == "Cache error" - - -# Test case for _generate_segment_dictionary_ with mixed segment types -def test_generate_segment_dictionary_mixed_types(): - #Test _generate_segment_dictionary_ with mixed segment types including SOURCE# - segments_response = SegmentResponse( - segments=[ - SegmentDTO( - id="content_seg", - text_id="text_id", - content="content text", - type=SegmentType.CONTENT - ), - SegmentDTO( - id="source_seg", - text_id="text_id", - content="source_id", - type=SegmentType.SOURCE - ), - SegmentDTO( - id="image_seg", - text_id="text_id", - content="image_url", - type=SegmentType.IMAGE - ) - ] - ) - - result = _generate_segment_dictionary_(new_segments=segments_response) - - # Should only contain non-SOURCE segments - content_hash = hashlib.sha256("content text".encode()).hexdigest() - image_hash = hashlib.sha256("image_url".encode()).hexdigest() - source_hash = hashlib.sha256("source_id".encode()).hexdigest() - - assert content_hash in result - assert image_hash in result - assert source_hash not in result - assert result[content_hash] == "content_seg" - assert result[image_hash] == "image_seg" - - -# Test case for _generate_segment_creation_request_payload_ with only SOURCE segments -def test_generate_segment_creation_request_payload_only_sources(): - #Test _generate_segment_creation_request_payload_ with only SOURCE type segments# - create_request = CreateSheetRequest( - title="Test Sheet", - source=[ - Source(position=1, type=SegmentType.SOURCE, content="source_id_1"), - Source(position=2, type=SegmentType.SOURCE, content="source_id_2") - ], - is_published=True - ) - - result = _generate_segment_creation_request_payload_( - create_sheet_request=create_request, - text_id="test_text_id" - ) - - assert isinstance(result, CreateSegmentRequest) - assert result.text_id == "test_text_id" - assert len(result.segments) == 0 # Should exclude all SOURCE segments - - -# Test case for _create_sheet_text_ with invalid token -@pytest.mark.asyncio -async def test_create_sheet_text_invalid_token(): - #Test _create_sheet_text_ with invalid token# - with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", side_effect=HTTPException(status_code=401, detail="Invalid token")): - - with pytest.raises(HTTPException) as exc_info: - await _create_sheet_text_( - title="Test Sheet", - token="invalid_token", - group_id="group_id" - ) - - assert exc_info.value.status_code == 401 - - -# Test case for _create_sheet_group_ with invalid token -@pytest.mark.asyncio -async def test_create_sheet_group_invalid_token(): - #Test _create_sheet_group_ with invalid token# - with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, side_effect=HTTPException(status_code=401, detail="Invalid token")): - - with pytest.raises(HTTPException) as exc_info: - await _create_sheet_group_(token="invalid_token") - - assert exc_info.value.status_code == 401 \ No newline at end of file diff --git a/tests/sheets/test_sheets_views.py b/tests/sheets/test_sheets_views.py deleted file mode 100644 index b0884998a..000000000 --- a/tests/sheets/test_sheets_views.py +++ /dev/null @@ -1,149 +0,0 @@ -import pytest -from unittest.mock import patch, AsyncMock - - -from pecha_api.sheets.sheets_views import ( - create_sheet, - get_sheet -) -from pecha_api.sheets.sheets_response_models import ( - CreateSheetRequest, - SheetDetailDTO, - SheetSection, - Publisher -) - -from pecha_api.texts.texts_response_models import TableOfContent, TableOfContentType, Section, TextSegment - -@pytest.mark.asyncio -async def test_create_sheet_success(): - mock_source = [] - mock_create_sheet_request = CreateSheetRequest( - title="sheet_title", - source=mock_source - ) - - mock_table_of_content_response = TableOfContent( - id="table_of_content_id", - text_id="text_id", - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_id", - section_number=1, - segments=[ - TextSegment( - segment_id="segment_id_1", - segment_number=1 - ), - TextSegment( - segment_id="segment_id_2", - segment_number=2 - ), - TextSegment( - segment_id="segment_id_3", - segment_number=3 - ) - ] - ) - ] - ) - - with patch("pecha_api.sheets.sheets_views.create_new_sheet", new_callable=AsyncMock, return_value=mock_table_of_content_response): - - response = await create_sheet( - create_sheet_request=mock_create_sheet_request, - authentication_credential=type("HTTPAuthorizationCredentials", (), {"credentials": "valid_token"})() - ) - - assert response is not None - assert isinstance(response, TableOfContent) - assert response.id == "table_of_content_id" - assert response.text_id == "text_id" - - -@pytest.mark.asyncio -async def test_get_sheet_success(): - sheet_id = "test_sheet_id" - skip = 0 - limit = 10 - - mock_sheet_detail = SheetDetailDTO( - id=sheet_id, - sheet_title="Test Sheet Title", - created_date="2024-01-01T00:00:00Z", - publisher=Publisher( - name="Test User", - username="testuser", - email="test@example.com" - ), - content=SheetSection( - section_number=1, - segments=[] - ), - views=0, - is_published=True, - skip=skip, - limit=limit, - total=1 - ) - - with patch("pecha_api.sheets.sheets_views.get_sheet_by_id", new_callable=AsyncMock, return_value=mock_sheet_detail): - response = await get_sheet( - sheet_id=sheet_id, - skip=skip, - limit=limit - ) - - assert response is not None - assert isinstance(response, SheetDetailDTO) - assert response.id == sheet_id - assert response.sheet_title == "Test Sheet Title" - assert response.publisher.name == "Test User" - assert response.publisher.email == "test@example.com" - assert response.skip == skip - assert response.limit == limit - assert response.total == 1 - - -@pytest.mark.asyncio -async def test_get_sheet_with_custom_pagination(): - sheet_id = "test_sheet_id" - skip = 5 - limit = 20 - - mock_sheet_detail = SheetDetailDTO( - id=sheet_id, - sheet_title="Test Sheet Title", - created_date="2024-01-01T00:00:00Z", - publisher=Publisher( - name="Test User", - username="testuser", - email="test@example.com" - ), - content=SheetSection( - section_number=1, - segments=[] - ), - views=0, - is_published=True, - skip=skip, - limit=limit, - total=1 - ) - - with patch("pecha_api.sheets.sheets_views.get_sheet_by_id", new_callable=AsyncMock, return_value=mock_sheet_detail) as mock_service: - response = await get_sheet( - sheet_id=sheet_id, - skip=skip, - limit=limit - ) - - # Verify the service function was called with correct parameters - mock_service.assert_called_once_with(sheet_id=sheet_id, skip=skip, limit=limit) - - assert response is not None - assert isinstance(response, SheetDetailDTO) - assert response.id == sheet_id - assert response.skip == skip - assert response.limit == limit \ No newline at end of file diff --git a/tests/text_uploader/__init__.py b/tests/text_uploader/__init__.py deleted file mode 100644 index a83913065..000000000 --- a/tests/text_uploader/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Tests for text uploader module.""" - - - - - diff --git a/tests/text_uploader/collections/__init__.py b/tests/text_uploader/collections/__init__.py deleted file mode 100644 index 869be8990..000000000 --- a/tests/text_uploader/collections/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Collection-related tests for text uploader.""" - - - - - diff --git a/tests/text_uploader/collections/test_collection_service.py b/tests/text_uploader/collections/test_collection_service.py deleted file mode 100644 index dac7550da..000000000 --- a/tests/text_uploader/collections/test_collection_service.py +++ /dev/null @@ -1,701 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch - -from pecha_api.text_uploader.collections.collection_service import CollectionService - - -def test_build_multilingual_payload_merges_languages_and_sets_slug_from_en_title(): - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "_id": {"$oid": "c1"}, - "title": "Liturgy", - "description": "Prayers", - "parent_id": None, - "has_sub_child": False, - } - ], - }, - { - "language": "bo", - "collections": [ - { - "_id": {"$oid": "c1"}, - "title": "ཁ་འདོན།", - "description": "ཆོ་ག", - "parent_id": None, - "has_sub_child": False, - } - ], - }, - ] - - payloads = service.build_multilingual_payload(collections_by_language) - assert len(payloads) == 1 - - payload = payloads[0] - assert payload["pecha_collection_id"] == "c1" - assert payload["slug"] == "Liturgy" - assert payload["titles"]["en"] == "Liturgy" - assert payload["titles"]["bo"] == "ཁ་འདོན།" - assert payload["descriptions"]["en"] == "Prayers" - assert payload["descriptions"]["bo"] == "ཆོ་ག" - # configured languages should always exist as keys - assert "zh" in payload["descriptions"] - - -@pytest.mark.asyncio -async def test_build_recursive_multilingual_payloads_recurse_children_uploads_all_levels(): - service = CollectionService() - - root_level = [ - { - "language": "en", - "collections": [ - { - "_id": {"$oid": "c1"}, - "title": "Root", - "description": "Root desc", - "parent_id": None, - "has_sub_child": True, - } - ], - }, - {"language": "bo", "collections": [{"_id": {"$oid": "c1"}, "title": "རྩ་བ།"}]}, - {"language": "zh", "collections": [{"_id": {"$oid": "c1"}, "title": "根"}]}, - ] - child_level = [ - { - "language": "en", - "collections": [ - { - "_id": {"$oid": "c2"}, - "title": "Child", - "description": "Child desc", - "parent_id": {"$oid": "c1"}, - "has_sub_child": False, - } - ], - }, - {"language": "bo", "collections": [{"_id": {"$oid": "c2"}, "title": "བུ།"}]}, - {"language": "zh", "collections": [{"_id": {"$oid": "c2"}, "title": "子"}]}, - ] - - async def fake_get_collections_service(*, openpecha_api_url: str, parent_id=None): - if parent_id is None: - return root_level - if parent_id == "c1": - return child_level - return [{"language": "en", "collections": []}] - - with patch.object( - service, - "get_collections_service", - new=AsyncMock(side_effect=fake_get_collections_service), - ), patch( - "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value=None, - ), patch( - "pecha_api.text_uploader.collections.collection_service.post_collections", - new_callable=AsyncMock, - side_effect=[ - {"id": {"$oid": "local_c1"}}, - {"id": "local_c2"}, - ], - ) as mock_post: - payloads = await service.build_recursive_multilingual_payloads( - destination_url="https://dest.example/api/v1", - openpecha_api_url="https://openpecha.example", - access_token="tok", - ) - - assert mock_post.await_count == 2 - - assert len(payloads) == 1 - root_payload = payloads[0] - assert root_payload["pecha_collection_id"] == "c1" - assert root_payload["local_id"] == "local_c1" - - assert "children" in root_payload - assert len(root_payload["children"]) == 1 - child_payload = root_payload["children"][0] - assert child_payload["pecha_collection_id"] == "c2" - assert child_payload["local_id"] == "local_c2" - - # Ensure recursion used the root's local_id as the child's parent_id in POST. - first_call = mock_post.await_args_list[0] - second_call = mock_post.await_args_list[1] - assert first_call.kwargs["collection_model"].parent_id is None - assert second_call.kwargs["collection_model"].parent_id == "local_c1" - -import pytest -from unittest.mock import AsyncMock, patch, MagicMock - -from pecha_api.text_uploader.collections.collection_service import CollectionService -from pecha_api.text_uploader.constants import COLLECTION_LANGUAGES -from pecha_api.text_uploader.collections.collection_model import CollectionPayload -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - - -@pytest.mark.asyncio -@pytest.mark.parametrize("parent_id", [None, "parent_1"]) -async def test_get_collections_service_delegates_to_repository(parent_id: str | None): - expected = [{"language": "en", "collections": [{"id": "c1"}]}] - - with patch( - "pecha_api.text_uploader.collections.collection_service.get_collections", - new_callable=AsyncMock, - return_value=expected, - ) as mock_get_collections: - service = CollectionService() - - result = await service.get_collections_service( - openpecha_api_url="https://openpecha.example", - parent_id=parent_id, - ) - - assert result == expected - mock_get_collections.assert_awaited_once_with( - openpecha_api_url="https://openpecha.example", - languages=COLLECTION_LANGUAGES, - parent_id=parent_id, - ) - - -@pytest.mark.asyncio -async def test_upload_collections_success(): - """Test upload_collections calls build_recursive_multilingual_payloads""" - service = CollectionService() - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="test_text_id" - ) - token = "test_token" - - with patch.object( - service, - "build_recursive_multilingual_payloads", - new_callable=AsyncMock - ) as mock_build: - await service.upload_collections( - text_upload_request=text_upload_request, - token=token - ) - - mock_build.assert_awaited_once_with( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - access_token=token - ) - - -@pytest.mark.asyncio -async def test_build_multilingual_payload_single_language(): - """Test build_multilingual_payload with single language collections""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_id_1", - "slug": "test-collection", - "title": "Test Collection", - "description": "Test Description", - "parent_id": None, - "has_sub_child": False - } - ] - } - ] - - result = service.build_multilingual_payload(collections_by_language) - - assert len(result) == 1 - assert result[0]["pecha_collection_id"] == "collection_id_1" - assert result[0]["slug"] == "Test Collection" - assert result[0]["titles"] == {"en": "Test Collection"} - assert result[0]["descriptions"]["en"] == "Test Description" - assert result[0]["parent_id"] is None - assert result[0]["has_sub_child"] is False - - -@pytest.mark.asyncio -async def test_build_multilingual_payload_multiple_languages(): - """Test build_multilingual_payload with multiple languages for same collection""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_id_1", - "slug": "madhyamaka", - "title": "Madhyamaka", - "description": "Madhyamaka treatises", - "parent_id": None, - "has_sub_child": True - } - ] - }, - { - "language": "bo", - "collections": [ - { - "id": "collection_id_1", - "slug": "madhyamaka", - "title": "དབུ་མ།", - "description": "དབུ་མའི་གཞུང་སྣ་ཚོགས།", - "parent_id": None, - "has_sub_child": True - } - ] - } - ] - - result = service.build_multilingual_payload(collections_by_language) - - assert len(result) == 1 - assert result[0]["pecha_collection_id"] == "collection_id_1" - assert result[0]["slug"] == "Madhyamaka" # English title is used as slug - assert result[0]["titles"] == {"en": "Madhyamaka", "bo": "དབུ་མ།"} - assert result[0]["descriptions"]["en"] == "Madhyamaka treatises" - assert result[0]["descriptions"]["bo"] == "དབུ་མའི་གཞུང་སྣ་ཚོགས།" - assert result[0]["has_sub_child"] is True - - -@pytest.mark.asyncio -async def test_build_multilingual_payload_with_oid_format(): - """Test build_multilingual_payload with MongoDB ObjectId format""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "_id": {"$oid": "507f1f77bcf86cd799439011"}, - "slug": "test", - "title": "Test Collection", - "description": "Test Description", - "parent_id": {"$oid": "507f1f77bcf86cd799439012"}, - "has_child": True - } - ] - } - ] - - result = service.build_multilingual_payload(collections_by_language) - - assert len(result) == 1 - assert result[0]["pecha_collection_id"] == "507f1f77bcf86cd799439011" - assert result[0]["parent_id"] == {"$oid": "507f1f77bcf86cd799439012"} - assert result[0]["has_sub_child"] is True - - -@pytest.mark.asyncio -async def test_build_multilingual_payload_multiple_collections(): - """Test build_multilingual_payload with multiple different collections""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_1", - "title": "Collection 1", - "description": "Description 1", - "has_sub_child": False - }, - { - "id": "collection_2", - "title": "Collection 2", - "description": "Description 2", - "has_sub_child": True - } - ] - } - ] - - result = service.build_multilingual_payload(collections_by_language) - - assert len(result) == 2 - collection_ids = [r["pecha_collection_id"] for r in result] - assert "collection_1" in collection_ids - assert "collection_2" in collection_ids - - -@pytest.mark.asyncio -async def test_build_multilingual_payload_with_titles_dict(): - """Test build_multilingual_payload when titles/descriptions are already dicts""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_1", - "slug": "test", - "titles": {"en": "English Title", "bo": "བོད་ཡིག"}, - "descriptions": {"en": "English Description"}, - "has_sub_child": False - } - ] - } - ] - - result = service.build_multilingual_payload(collections_by_language) - - assert len(result) == 1 - assert result[0]["titles"]["en"] == "English Title" - assert result[0]["slug"] == "English Title" - - -@pytest.mark.asyncio -async def test_build_recursive_multilingual_payloads_no_children(): - """Test build_recursive_multilingual_payloads with collections that have no children""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_1", - "title": "Test Collection", - "description": "Test Description", - "parent_id": None, - "has_sub_child": False - } - ] - } - ] - - with patch.object( - service, - "get_collections_service", - new_callable=AsyncMock, - return_value=collections_by_language - ) as mock_get_collections, \ - patch( - "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value=None - ) as mock_get_existing, \ - patch( - "pecha_api.text_uploader.collections.collection_service.post_collections", - new_callable=AsyncMock, - return_value={"id": "new_local_id_1"} - ) as mock_post: - result = await service.build_recursive_multilingual_payloads( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - access_token="test_token" - ) - - assert len(result) == 1 - assert result[0]["pecha_collection_id"] == "collection_1" - assert result[0]["children"] == [] - assert result[0]["local_id"] == "new_local_id_1" - mock_post.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_build_recursive_multilingual_payloads_multiple_collections_no_children(): - """Test build_recursive_multilingual_payloads with multiple collections without children""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_1", - "title": "Collection 1", - "description": "Description 1", - "has_sub_child": False - }, - { - "id": "collection_2", - "title": "Collection 2", - "description": "Description 2", - "has_sub_child": False - } - ] - } - ] - - with patch.object( - service, - "get_collections_service", - new_callable=AsyncMock, - return_value=collections_by_language - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value=None - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.post_collections", - new_callable=AsyncMock, - side_effect=[ - {"id": "local_1"}, - {"id": "local_2"} - ] - ) as mock_post: - result = await service.build_recursive_multilingual_payloads( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - access_token="test_token" - ) - - assert len(result) == 2 - assert result[0]["pecha_collection_id"] == "collection_1" - assert result[0]["local_id"] == "local_1" - assert result[0]["children"] == [] - assert result[1]["pecha_collection_id"] == "collection_2" - assert result[1]["local_id"] == "local_2" - assert result[1]["children"] == [] - assert mock_post.await_count == 2 - - -@pytest.mark.asyncio -async def test_build_recursive_multilingual_payloads_existing_collection(): - """Test build_recursive_multilingual_payloads skips existing collections""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "existing_collection", - "title": "Existing Collection", - "description": "Already exists", - "has_sub_child": False - } - ] - } - ] - - with patch.object( - service, - "get_collections_service", - new_callable=AsyncMock, - return_value=collections_by_language - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value="existing_local_id" - ) as mock_get_existing, \ - patch( - "pecha_api.text_uploader.collections.collection_service.post_collections", - new_callable=AsyncMock - ) as mock_post: - result = await service.build_recursive_multilingual_payloads( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - access_token="test_token" - ) - - assert len(result) == 1 - mock_get_existing.assert_awaited_once() - mock_post.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_build_recursive_multilingual_payloads_with_oid_local_id(): - """Test build_recursive_multilingual_payloads handles ObjectId format for local_id""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_1", - "title": "Test Collection", - "has_sub_child": False - } - ] - } - ] - - with patch.object( - service, - "get_collections_service", - new_callable=AsyncMock, - return_value=collections_by_language - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value=None - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.post_collections", - new_callable=AsyncMock, - return_value={"_id": {"$oid": "507f1f77bcf86cd799439011"}} - ): - result = await service.build_recursive_multilingual_payloads( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - access_token="test_token" - ) - - assert len(result) == 1 - assert result[0]["local_id"] == "507f1f77bcf86cd799439011" - - -@pytest.mark.asyncio -async def test_build_recursive_multilingual_payloads_with_local_parent_id(): - """Test build_recursive_multilingual_payloads uses provided local_parent_id""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "child_collection", - "title": "Child Collection", - "parent_id": "remote_parent_id", - "has_sub_child": False - } - ] - } - ] - - with patch.object( - service, - "get_collections_service", - new_callable=AsyncMock, - return_value=collections_by_language - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value=None - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.post_collections", - new_callable=AsyncMock, - return_value={"id": "new_local_id"} - ) as mock_post: - result = await service.build_recursive_multilingual_payloads( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - access_token="test_token", - local_parent_id="local_parent_id_123" - ) - - assert len(result) == 1 - # Verify that the collection was posted with the correct parent_id - call_args = mock_post.call_args - assert call_args[1]["collection_model"].parent_id == "local_parent_id_123" - - -@pytest.mark.asyncio -async def test_build_multilingual_payload_empty_collections(): - """Test build_multilingual_payload with empty collections""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [] - } - ] - - result = service.build_multilingual_payload(collections_by_language) - - assert len(result) == 0 - assert result == [] - - -@pytest.mark.asyncio -async def test_build_multilingual_payload_none_descriptions(): - """Test build_multilingual_payload handles None descriptions""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "collection_1", - "title": "Test Collection", - "description": None, - "has_sub_child": False - } - ] - } - ] - - result = service.build_multilingual_payload(collections_by_language) - - assert len(result) == 1 - assert result[0]["titles"]["en"] == "Test Collection" - # Description for 'en' should be empty string (from initialization) - assert "descriptions" in result[0] - - -@pytest.mark.asyncio -async def test_build_recursive_multilingual_payloads_no_remote_parent_id(): - """Test build_recursive_multilingual_payloads handles missing remote parent id""" - service = CollectionService() - - collections_by_language = [ - { - "language": "en", - "collections": [ - { - "id": "valid_id", # Valid ID but will test edge case - "title": "Test Collection", - "has_sub_child": False # No children to avoid recursion bug - } - ] - } - ] - - with patch.object( - service, - "get_collections_service", - new_callable=AsyncMock, - return_value=collections_by_language - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value=None - ), \ - patch( - "pecha_api.text_uploader.collections.collection_service.post_collections", - new_callable=AsyncMock, - return_value={"id": "new_id"} - ): - result = await service.build_recursive_multilingual_payloads( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - access_token="test_token" - ) - - assert len(result) == 1 - assert result[0]["pecha_collection_id"] == "valid_id" - # Should have empty children since has_sub_child is False - assert result[0]["children"] == [] - - diff --git a/tests/text_uploader/mapping/test_mapping_service.py b/tests/text_uploader/mapping/test_mapping_service.py deleted file mode 100644 index 0e8e46b9a..000000000 --- a/tests/text_uploader/mapping/test_mapping_service.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch - -from pecha_api.text_uploader.mapping.mapping_services import MappingService -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - - -@pytest.mark.asyncio -async def test_mapping_service_triggers_repo_with_text_ids_and_urls(): - service = MappingService() - request = TextUploadRequest( - destination_url="https://dest.example/api/v1", - openpecha_api_url="https://openpecha.example", - text_id="T1", - ) - - with patch( - "pecha_api.text_uploader.mapping.mapping_services.trigger_mapping_repo", - new_callable=AsyncMock, - ) as mock_trigger: - await service.trigger_mapping( - text_ids={"a": "t1", "b": "t2"}, - text_upload_request=request, - ) - - mock_trigger.assert_awaited_once_with( - ["t1", "t2"], - "https://openpecha.example", - "https://dest.example/api/v1", - ) diff --git a/tests/text_uploader/mapping/test_mapping_services.py b/tests/text_uploader/mapping/test_mapping_services.py deleted file mode 100644 index f9587d3e4..000000000 --- a/tests/text_uploader/mapping/test_mapping_services.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch - -from pecha_api.text_uploader.mapping.mapping_services import MappingService -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - - -@pytest.mark.asyncio -async def test_trigger_mapping_passes_text_ids_and_urls_to_repo(): - service = MappingService() - request = TextUploadRequest( - destination_url="https://dest.example", - openpecha_api_url="https://openpecha.example", - text_id="t1", - ) - - with patch( - "pecha_api.text_uploader.mapping.mapping_services.trigger_mapping_repo", - new_callable=AsyncMock, - ) as mock_trigger: - await service.trigger_mapping( - text_ids={"a": "i1", "b": "i2"}, - text_upload_request=request, - ) - - mock_trigger.assert_awaited_once_with( - ["i1", "i2"], - "https://openpecha.example", - "https://dest.example", - ) - diff --git a/tests/text_uploader/segments/__init__.py b/tests/text_uploader/segments/__init__.py deleted file mode 100644 index edaedb4e7..000000000 --- a/tests/text_uploader/segments/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Segment-related tests for text uploader.""" - - - - - diff --git a/tests/text_uploader/segments/test_segment_model.py b/tests/text_uploader/segments/test_segment_model.py deleted file mode 100644 index 096c7083f..000000000 --- a/tests/text_uploader/segments/test_segment_model.py +++ /dev/null @@ -1,27 +0,0 @@ -from pecha_api.text_uploader.segments.segment_model import ( - ManifestationModel, - Segment, - SegmentModel, -) - - -def test_segment_models_validate_and_dump(): - model = SegmentModel( - text_id="t1", - segments=[ - Segment(content="a", type="source"), - Segment(content="b", type="source"), - ], - ) - - dumped = model.model_dump() - assert dumped["text_id"] == "t1" - assert dumped["segments"][0] == {"content": "a", "type": "source"} - - -def test_manifestation_model_defaults_none(): - model = ManifestationModel() - assert model.job_id is None - assert model.status is None - assert model.message is None - diff --git a/tests/text_uploader/segments/test_segment_repository.py b/tests/text_uploader/segments/test_segment_repository.py deleted file mode 100644 index 718c52112..000000000 --- a/tests/text_uploader/segments/test_segment_repository.py +++ /dev/null @@ -1,218 +0,0 @@ -import requests -import pytest -from unittest.mock import AsyncMock, patch - - -class DummyResponse: - def __init__( - self, - payload, - *, - ok: bool = True, - status_code: int = 200, - text: str = "", - raise_for_status_exc: Exception | None = None, - ): - self._payload = payload - self.ok = ok - self.status_code = status_code - self.text = text - self._raise_for_status_exc = raise_for_status_exc - - def raise_for_status(self): - if self._raise_for_status_exc: - raise self._raise_for_status_exc - - def json(self): - return self._payload - - -@pytest.mark.asyncio -async def test_get_segments_annotation_calls_instances_endpoint(): - from pecha_api.text_uploader.segments import segment_respository as repo - - response = DummyResponse([{"ok": True}]) - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - return_value=response, - ) as mock_to_thread: - result = await repo.get_segments_annotation( - pecha_text_id="P1", openpecha_api_url="https://openpecha.example" - ) - - assert result == [{"ok": True}] - assert mock_to_thread.await_count == 1 - call = mock_to_thread.await_args - assert call.args[0] is repo.requests.get - assert call.args[1] == "https://openpecha.example/v2/instances/P1" - assert call.kwargs["params"] == {"annotation": "true", "content": "true"} - - -@pytest.mark.asyncio -async def test_get_segments_id_by_annotation_id_calls_annotations_endpoint(): - from pecha_api.text_uploader.segments import segment_respository as repo - - response = DummyResponse([{"id": "a1"}]) - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - return_value=response, - ) as mock_to_thread: - result = await repo.get_segments_id_by_annotation_id( - annotation_id="ann_1", openpecha_api_url="https://openpecha.example" - ) - - assert result == [{"id": "a1"}] - call = mock_to_thread.await_args - assert call.args[0] is repo.requests.get - assert call.args[1] == "https://openpecha.example/v2/annotations/ann_1" - -@pytest.mark.asyncio -async def test_get_segments_by_id_calls_annotations_endpoint(): - from pecha_api.text_uploader.segments import segment_respository as repo - - response = DummyResponse({"data": [{"id": "seg"}]}) - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - return_value=response, - ) as mock_to_thread: - result = await repo.get_segments_by_id( - annotation_id="ann_2", - openpecha_api_url="https://openpecha.example", - ) - - assert result == {"data": [{"id": "seg"}]} - call = mock_to_thread.await_args - assert call.args[0] is repo.requests.get - assert call.args[1] == "https://openpecha.example/v2/annotations/ann_2" - - -@pytest.mark.asyncio -async def test_get_segment_content_success_posts_segment_ids_payload(): - from pecha_api.text_uploader.segments import segment_respository as repo - - response = DummyResponse([{"segment_id": "s1", "content": "c1"}]) - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - return_value=response, - ) as mock_to_thread: - result = await repo.get_segment_content( - segment_id=["s1", "s2"], - pecha_text_id="P1", - openpecha_api_url="https://openpecha.example", - ) - - assert result == [{"segment_id": "s1", "content": "c1"}] - call = mock_to_thread.await_args - assert call.args[0] is repo.requests.post - assert call.args[1] == "https://openpecha.example/v2/instances/P1/segment-content" - assert call.kwargs["json"] == {"segment_ids": ["s1", "s2"]} - - -@pytest.mark.asyncio -async def test_get_segment_content_raises_on_http_error(): - from pecha_api.text_uploader.segments import segment_respository as repo - - http_error = requests.exceptions.HTTPError("boom") - response = DummyResponse( - {"detail": "bad"}, - ok=False, - status_code=400, - text="bad", - raise_for_status_exc=http_error, - ) - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - return_value=response, - ): - with pytest.raises(requests.exceptions.HTTPError): - await repo.get_segment_content( - segment_id=["s1"], - pecha_text_id="P1", - openpecha_api_url="https://openpecha.example", - ) - -@pytest.mark.asyncio -async def test_get_segment_content_raises_on_request_exception(): - from pecha_api.text_uploader.segments import segment_respository as repo - - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - side_effect=requests.exceptions.RequestException("boom"), - ): - with pytest.raises(requests.exceptions.RequestException): - await repo.get_segment_content( - segment_id=["s1"], - pecha_text_id="P1", - openpecha_api_url="https://openpecha.example", - ) - - -@pytest.mark.asyncio -async def test_get_segment_content_raises_on_unexpected_exception(): - from pecha_api.text_uploader.segments import segment_respository as repo - - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - side_effect=RuntimeError("boom"), - ): - with pytest.raises(RuntimeError): - await repo.get_segment_content( - segment_id=["s1"], - pecha_text_id="P1", - openpecha_api_url="https://openpecha.example", - ) - - -@pytest.mark.asyncio -async def test_post_segments_returns_json_when_ok(): - from pecha_api.text_uploader.segments import segment_respository as repo - - response = DummyResponse({"id": "seg_1"}, ok=True) - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - return_value=response, - ) as mock_to_thread: - result = await repo.post_segments( - segments_payload={"text_id": "t1", "segments": []}, - destination_url="https://dest.example", - token="tok", - ) - - assert result == {"id": "seg_1"} - call = mock_to_thread.await_args - assert call.args[0] is repo.requests.post - assert call.args[1] == "https://dest.example/segments" - assert call.kwargs["headers"]["Authorization"] == "Bearer tok" - - -@pytest.mark.asyncio -async def test_post_segments_raises_for_non_ok_response(): - from pecha_api.text_uploader.segments import segment_respository as repo - - response = DummyResponse( - {"detail": "bad"}, - ok=False, - status_code=400, - text="bad", - raise_for_status_exc=requests.exceptions.HTTPError("boom"), - ) - with patch( - "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread", - new_callable=AsyncMock, - return_value=response, - ): - with pytest.raises(requests.exceptions.HTTPError): - await repo.post_segments( - segments_payload={"text_id": "t1", "segments": []}, - destination_url="https://dest.example", - token="tok", - ) - diff --git a/tests/text_uploader/segments/test_segment_service.py b/tests/text_uploader/segments/test_segment_service.py deleted file mode 100644 index 012f57ff7..000000000 --- a/tests/text_uploader/segments/test_segment_service.py +++ /dev/null @@ -1,221 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch - -from pecha_api.text_uploader.segments.segment_service import SegmentService -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - - -def test_segment_service_parse_segments_content_slices_base_text(): - service = SegmentService() - base_text = "abcdef" - segments = [ - {"id": "s1", "span": {"start": 0, "end": 3}}, - {"id": "s2", "span": {"start": 3, "end": 6}}, - ] - - parsed = service.parse_segments_content(segments_annotation=segments, base_text=base_text) - assert parsed == [ - {"segment_id": "s1", "content": "abc"}, - {"segment_id": "s2", "content": "def"}, - ] - - -def test_segment_service_get_annotation_ids_filters_segmentation_only(): - service = SegmentService() - instance = { - "annotations": [ - {"type": "topic", "annotation_id": "x"}, - {"type": "segmentation", "annotation_id": "seg_1"}, - {"type": "segmentation", "annotation_id": "seg_2"}, - ] - } - - assert service.get_annotation_ids(instance) == ["seg_1", "seg_2"] - - -@pytest.mark.asyncio -async def test_segment_service_is_text_segments_uploaded_true_when_any_segments_exist(): - service = SegmentService() - with patch( - "pecha_api.text_uploader.segments.segment_service.get_segments_by_text_id", - new_callable=AsyncMock, - return_value=[{"id": "1"}], - ): - assert await service.is_text_segments_uploaded("t1") is True - - -@pytest.mark.asyncio -async def test_segment_service_upload_bulk_segments_batches_and_posts(): - service = SegmentService() - request = TextUploadRequest( - destination_url="https://dest.example/api/v1", - openpecha_api_url="https://openpecha.example", - text_id="T1", - ) - segments_content = [{"segment_id": f"s{i}", "content": f"c{i}"} for i in range(401)] - - with patch( - "pecha_api.text_uploader.segments.segment_service.post_segments", - new_callable=AsyncMock, - return_value={"ok": True}, - ) as mock_post: - await service.upload_bulk_segments( - text_id="wb_text_1", - segments_content=segments_content, - text_upload_request=request, - token="tok", - ) - - assert mock_post.await_count == 2 - first_payload = mock_post.await_args_list[0].args[0] - second_payload = mock_post.await_args_list[1].args[0] - assert first_payload["text_id"] == "wb_text_1" - assert len(first_payload["segments"]) == 400 - assert len(second_payload["segments"]) == 1 - assert first_payload["segments"][0]["type"] == "source" - - -@pytest.mark.asyncio -async def test_segment_service_get_segments_by_id_list_calls_repo(): - service = SegmentService() - request = TextUploadRequest( - destination_url="https://dest.example/api/v1", - openpecha_api_url="https://openpecha.example", - text_id="T1", - ) - - with patch( - "pecha_api.text_uploader.segments.segment_service.get_segments_by_id", - new_callable=AsyncMock, - return_value={"data": [{"id": "ann"}]}, - ) as mock_get: - result = await service.get_segments_by_id_list("ann_1", request) - - assert result == {"data": [{"id": "ann"}]} - mock_get.assert_awaited_once_with("ann_1", "https://openpecha.example") - - -@pytest.mark.asyncio -async def test_segment_service_upload_segments_skips_when_already_uploaded(): - service = SegmentService() - request = TextUploadRequest( - destination_url="https://dest.example/api/v1", - openpecha_api_url="https://openpecha.example", - text_id="T1", - ) - - with patch.object( - service, "is_text_segments_uploaded", new_callable=AsyncMock, return_value=True - ), patch( - "pecha_api.text_uploader.segments.segment_service.get_segments_annotation", - new_callable=AsyncMock, - ) as mock_get_annotation: - await service.upload_segments( - text_upload_request=request, - text_ids={"wb_text_1": "pecha_instance_1"}, - token="tok", - ) - - assert mock_get_annotation.called is False - -import pytest -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -from pecha_api.text_uploader.segments.segment_service import SegmentService - - -def test_get_annotation_ids_filters_segmentation_only(): - service = SegmentService() - instance = { - "annotations": [ - {"type": "note", "annotation_id": "a0"}, - {"type": "segmentation", "annotation_id": "a1"}, - {"type": "segmentation", "annotation_id": "a2"}, - ] - } - - assert service.get_annotation_ids(instance) == ["a1", "a2"] - - -def test_get_annotation_ids_handles_none_annotations(): - service = SegmentService() - assert service.get_annotation_ids({"annotations": None}) == [] - - -def test_parse_segments_content_slices_base_text(): - service = SegmentService() - base_text = "abcdef" - segments_annotation = [ - {"id": "s1", "span": {"start": 0, "end": 2}}, - {"id": "s2", "span": {"start": 2, "end": 6}}, - ] - - assert service.parse_segments_content(segments_annotation, base_text) == [ - {"segment_id": "s1", "content": "ab"}, - {"segment_id": "s2", "content": "cdef"}, - ] - - -@pytest.mark.asyncio -async def test_is_text_segments_uploaded_true_when_any_segments_exist(): - service = SegmentService() - with patch( - "pecha_api.text_uploader.segments.segment_service.get_segments_by_text_id", - new_callable=AsyncMock, - return_value=[{"id": "seg_1"}], - ): - assert await service.is_text_segments_uploaded("text_1") is True - - -@pytest.mark.asyncio -async def test_upload_bulk_segments_batches_and_posts_payload(): - service = SegmentService() - text_upload_request = SimpleNamespace(destination_url="https://dest.example") - token = "t0k3n" - - # 401 segments -> two batches (400 + 1) - segments_content = [ - {"segment_id": f"s{i}", "content": f"c{i}"} for i in range(401) - ] - - with patch( - "pecha_api.text_uploader.segments.segment_service.post_segments", - new_callable=AsyncMock, - return_value={"ok": True}, - ) as mock_post_segments: - await service.upload_bulk_segments( - text_id="text_1", - segments_content=segments_content, - text_upload_request=text_upload_request, - token=token, - ) - - assert mock_post_segments.await_count == 2 - - first_payload = mock_post_segments.await_args_list[0].args[0] - second_payload = mock_post_segments.await_args_list[1].args[0] - - assert first_payload["text_id"] == "text_1" - assert len(first_payload["segments"]) == 400 - assert first_payload["segments"][0] == { - "pecha_segment_id": "s0", - "content": "c0", - "type": "source", - } - - assert second_payload["text_id"] == "text_1" - assert len(second_payload["segments"]) == 1 - assert second_payload["segments"][0] == { - "pecha_segment_id": "s400", - "content": "c400", - "type": "source", - } - - # Verify destination/token plumbing (payload, destination_url, token) - assert mock_post_segments.await_args_list[0].args[1:] == ( - "https://dest.example", - token, - ) - - diff --git a/tests/text_uploader/test_pipeline.py b/tests/text_uploader/test_pipeline.py deleted file mode 100644 index aa97b258a..000000000 --- a/tests/text_uploader/test_pipeline.py +++ /dev/null @@ -1,158 +0,0 @@ -import pytest -from fastapi import HTTPException -from unittest.mock import AsyncMock, patch - -from pecha_api.error_contants import ErrorConstants -from pecha_api.text_uploader.pipeline import pipeline -from pecha_api.text_uploader.text_metadata.text_metadata_model import TextInstanceIds -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - - -@pytest.mark.asyncio -async def test_pipeline_raises_403_when_not_admin(): - request = TextUploadRequest( - destination_url="LOCAL", - openpecha_api_url="DEVELOPMENT", - text_id="T1", - ) - - with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=False): - with pytest.raises(HTTPException) as exc: - await pipeline(text_upload_request=request, token="tok") - - assert exc.value.status_code == 403 - assert exc.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE - - -@pytest.mark.asyncio -async def test_pipeline_runs_segment_toc_and_returns_new_texts_when_any_new(): - request = TextUploadRequest( - destination_url="LOCAL", - openpecha_api_url="DEVELOPMENT", - text_id="T1", - ) - instance_ids = TextInstanceIds( - new_text={"wb_text_1": "pecha_instance_1"}, - all_text={"T1": "pecha_instance_1"}, - ) - - with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=True), patch( - "pecha_api.text_uploader.pipeline.CollectionService" - ) as MockCollectionService, patch( - "pecha_api.text_uploader.pipeline.TextMetadataService" - ) as MockTextMetadataService, patch( - "pecha_api.text_uploader.pipeline.SegmentService" - ) as MockSegmentService, patch( - "pecha_api.text_uploader.pipeline.TocService" - ) as MockTocService, patch( - "pecha_api.text_uploader.pipeline.MappingService" - ) as MockMappingService: - MockCollectionService.return_value.upload_collections = AsyncMock() - MockTextMetadataService.return_value.upload_text_metadata_service = AsyncMock( - return_value=instance_ids - ) - MockSegmentService.return_value.upload_segments = AsyncMock() - MockTocService.return_value.upload_toc = AsyncMock() - MockMappingService.return_value.trigger_mapping = AsyncMock() - - response = await pipeline(text_upload_request=request, token="tok") - - assert response.message == {"wb_text_1": "pecha_instance_1"} - - # Ensure services are wired with the resolved destination/openpecha URLs. - payload = MockCollectionService.return_value.upload_collections.await_args.kwargs[ - "text_upload_request" - ] - assert payload.destination_url.startswith("http") - assert payload.openpecha_api_url.startswith("https://") - assert payload.text_id == "T1" - - MockSegmentService.return_value.upload_segments.assert_awaited_once() - MockTocService.return_value.upload_toc.assert_awaited_once() - MockMappingService.return_value.trigger_mapping.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_pipeline_skips_segment_and_toc_when_no_new_texts(): - request = TextUploadRequest( - destination_url="LOCAL", - openpecha_api_url="DEVELOPMENT", - text_id="T1", - ) - instance_ids = TextInstanceIds(new_text={}, all_text={"T1": "pecha_instance_1"}) - - with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=True), patch( - "pecha_api.text_uploader.pipeline.CollectionService" - ) as MockCollectionService, patch( - "pecha_api.text_uploader.pipeline.TextMetadataService" - ) as MockTextMetadataService, patch( - "pecha_api.text_uploader.pipeline.SegmentService" - ) as MockSegmentService, patch( - "pecha_api.text_uploader.pipeline.TocService" - ) as MockTocService, patch( - "pecha_api.text_uploader.pipeline.MappingService" - ) as MockMappingService: - MockCollectionService.return_value.upload_collections = AsyncMock() - MockTextMetadataService.return_value.upload_text_metadata_service = AsyncMock( - return_value=instance_ids - ) - MockMappingService.return_value.trigger_mapping = AsyncMock() - - response = await pipeline(text_upload_request=request, token="tok") - - assert response.message == "All texts are already uploaded" - assert MockSegmentService.called is False - assert MockTocService.called is False - MockMappingService.return_value.trigger_mapping.assert_not_awaited() - - payload = MockCollectionService.return_value.upload_collections.await_args.kwargs[ - "text_upload_request" - ] - # Mapping is intentionally skipped when destination_url is LOCAL. - - -@pytest.mark.asyncio -async def test_pipeline_triggers_mapping_when_destination_is_not_local(): - request = TextUploadRequest( - destination_url="DEVELOPMENT", - openpecha_api_url="DEVELOPMENT", - text_id="T1", - ) - instance_ids = TextInstanceIds( - new_text={"wb_text_1": "pecha_instance_1"}, - all_text={"T1": "pecha_instance_1"}, - ) - - with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=True), patch( - "pecha_api.text_uploader.pipeline.CollectionService" - ) as MockCollectionService, patch( - "pecha_api.text_uploader.pipeline.TextMetadataService" - ) as MockTextMetadataService, patch( - "pecha_api.text_uploader.pipeline.SegmentService" - ) as MockSegmentService, patch( - "pecha_api.text_uploader.pipeline.TocService" - ) as MockTocService, patch( - "pecha_api.text_uploader.pipeline.MappingService" - ) as MockMappingService: - MockCollectionService.return_value.upload_collections = AsyncMock() - MockTextMetadataService.return_value.upload_text_metadata_service = AsyncMock( - return_value=instance_ids - ) - MockSegmentService.return_value.upload_segments = AsyncMock() - MockTocService.return_value.upload_toc = AsyncMock() - MockMappingService.return_value.trigger_mapping = AsyncMock() - - response = await pipeline(text_upload_request=request, token="tok") - - assert response.message == {"wb_text_1": "pecha_instance_1"} - MockMappingService.return_value.trigger_mapping.assert_awaited_once() - - trigger_kwargs = MockMappingService.return_value.trigger_mapping.await_args.kwargs - assert trigger_kwargs["text_ids"] == {"T1": "pecha_instance_1"} - - called_payload = trigger_kwargs["text_upload_request"] - # Mapping is triggered with the original (enum) request values, not the resolved URL payload. - assert called_payload.destination_url == "DEVELOPMENT" - assert called_payload.openpecha_api_url == "DEVELOPMENT" - assert called_payload.text_id == "T1" - diff --git a/tests/text_uploader/test_text_uploader_views.py b/tests/text_uploader/test_text_uploader_views.py deleted file mode 100644 index a3b4b2042..000000000 --- a/tests/text_uploader/test_text_uploader_views.py +++ /dev/null @@ -1,181 +0,0 @@ -from fastapi.testclient import TestClient -from unittest.mock import patch, AsyncMock -import pytest - -from pecha_api.app import api -from pecha_api.error_contants import ErrorConstants -from pecha_api.text_uploader.text_uploader_response_model import TextUploadResponse - -client = TestClient(api) - - -class TestTextUploaderViews: - """Test cases for text uploader views.""" - - @pytest.fixture - def mock_text_upload_request(self): - """Fixture for text upload request data.""" - return { - "destination_url": "DEVELOPMENT", - "openpecha_api_url": "DEVELOPMENT", - "text_id": "test_text_123" - } - - @pytest.fixture - def admin_token(self): - """Fixture for admin authentication token.""" - return "Bearer admin_test_token" - - @pytest.fixture - def non_admin_token(self): - """Fixture for non-admin authentication token.""" - return "Bearer non_admin_test_token" - - def test_upload_text_success_with_admin_token(self, mock_text_upload_request, admin_token): - """Test successful text upload with admin token.""" - with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline: - mock_pipeline.return_value = TextUploadResponse(message={"text_1": "instance_1"}) - - response = client.post( - "/text-uploader", - json=mock_text_upload_request, - headers={"Authorization": admin_token} - ) - - assert response.status_code == 200 - assert response.json() == {"message": {"text_1": "instance_1"}} - mock_pipeline.assert_called_once() - - def test_upload_text_forbidden_with_non_admin_token(self, mock_text_upload_request, non_admin_token): - """Test text upload fails with non-admin token (403 Forbidden).""" - with patch("pecha_api.text_uploader.pipeline.verify_admin_access") as mock_verify_admin: - mock_verify_admin.return_value = False - - response = client.post( - "/text-uploader", - json=mock_text_upload_request, - headers={"Authorization": non_admin_token} - ) - - assert response.status_code == 403 - assert response.json()["detail"] == ErrorConstants.ADMIN_ERROR_MESSAGE - - def test_upload_text_unauthorized_without_token(self, mock_text_upload_request): - """Test text upload fails without authentication token (401 Unauthorized).""" - response = client.post( - "/text-uploader", - json=mock_text_upload_request - ) - - assert response.status_code == 403 - - def test_upload_text_invalid_request_missing_destination_url(self, admin_token): - """Test text upload fails with missing destination_url.""" - invalid_request = { - "openpecha_api_url": "DEVELOPMENT", - "text_id": "test_text_123" - } - - response = client.post( - "/text-uploader", - json=invalid_request, - headers={"Authorization": admin_token} - ) - - assert response.status_code == 422 - - def test_upload_text_invalid_request_missing_openpecha_api_url(self, admin_token): - """Test text upload fails with missing openpecha_api_url.""" - invalid_request = { - "destination_url": "DEVELOPMENT", - "text_id": "test_text_123" - } - - response = client.post( - "/text-uploader", - json=invalid_request, - headers={"Authorization": admin_token} - ) - - assert response.status_code == 422 - - def test_upload_text_invalid_request_missing_text_id(self, admin_token): - """Test text upload fails with missing text_id.""" - invalid_request = { - "destination_url": "DEVELOPMENT", - "openpecha_api_url": "DEVELOPMENT" - } - - response = client.post( - "/text-uploader", - json=invalid_request, - headers={"Authorization": admin_token} - ) - - assert response.status_code == 422 - - def test_upload_text_empty_request_body(self, admin_token): - """Test text upload fails with empty request body.""" - response = client.post( - "/text-uploader", - json={}, - headers={"Authorization": admin_token} - ) - - assert response.status_code == 422 - - def test_upload_text_pipeline_exception_handling(self, mock_text_upload_request, admin_token): - """Test that exceptions from pipeline are properly propagated.""" - with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline: - mock_pipeline.side_effect = Exception("Pipeline processing failed") - - with pytest.raises(Exception) as exc_info: - response = client.post( - "/text-uploader", - json=mock_text_upload_request, - headers={"Authorization": admin_token} - ) - - assert str(exc_info.value) == "Pipeline processing failed" - - def test_upload_text_with_special_characters_in_text_id(self, admin_token): - """Test text upload with special characters in text_id.""" - request_with_special_chars = { - "destination_url": "DEVELOPMENT", - "openpecha_api_url": "DEVELOPMENT", - "text_id": "test_text_!@#$%^&*()" - } - - with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline: - mock_pipeline.return_value = TextUploadResponse(message={"text_special": "instance_special"}) - - response = client.post( - "/text-uploader", - json=request_with_special_chars, - headers={"Authorization": admin_token} - ) - - assert response.status_code == 200 - assert response.json() == {"message": {"text_special": "instance_special"}} - - def test_upload_text_with_extra_fields(self, admin_token): - """Test text upload with extra fields in request (should be ignored by Pydantic).""" - request_with_extra_fields = { - "destination_url": "DEVELOPMENT", - "openpecha_api_url": "DEVELOPMENT", - "text_id": "test_text_123", - "extra_field": "should_be_ignored" - } - - with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline: - mock_pipeline.return_value = TextUploadResponse(message={"text_extra": "instance_extra"}) - - response = client.post( - "/text-uploader", - json=request_with_extra_fields, - headers={"Authorization": admin_token} - ) - - assert response.status_code == 200 - assert response.json() == {"message": {"text_extra": "instance_extra"}} - diff --git a/tests/text_uploader/texts/__init__.py b/tests/text_uploader/texts/__init__.py deleted file mode 100644 index cc5244580..000000000 --- a/tests/text_uploader/texts/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Text-related tests for text uploader.""" - - - - - diff --git a/tests/text_uploader/texts/test_text_metadata_service.py b/tests/text_uploader/texts/test_text_metadata_service.py deleted file mode 100644 index 7781ed6d5..000000000 --- a/tests/text_uploader/texts/test_text_metadata_service.py +++ /dev/null @@ -1,573 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock - -from pecha_api.text_uploader.text_metadata.text_metadata_service import TextMetadataService -from pecha_api.text_uploader.text_metadata.text_metadata_model import ( - CriticalInstance, - CriticalInstanceResponse, - TextInstanceIds -) -from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest - - -@pytest.mark.asyncio -async def test_create_textmetada_payload_version_sets_group_and_category_from_collection(): - service = TextMetadataService() - service.version_group_id = "group_v1" - - text_metadata = { - "language": "en", - "title": {"en": "A Title"}, - "category_id": "pecha_cat_1", - "isPublished": True, - "views": 12, - "ranking": 3, - "license": "CC0", - } - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="t1" - ) - - with patch.object( - service, - "get_text_critical_instance", - new_callable=AsyncMock, - return_value=CriticalInstanceResponse( - critical_instances=[ - CriticalInstance( - id="inst_1", - type="critical", - source="https://source.example", - alt_incipit_titles=None, - ) - ] - ), - ), patch.object( - service, - "get_wb_collection_id", - new_callable=AsyncMock, - return_value="wb_cat_1" - ): - payload = await service.create_textmetada_payload( - text_id="t1", - text_metadata=text_metadata, - type="version", - text_upload_request=text_upload_request - ) - - assert payload.pecha_text_id == "inst_1" - assert payload.title == "A Title" - assert payload.language == "en" - assert payload.group_id == "group_v1" - assert payload.categories == ["wb_cat_1"] - assert payload.type == "version" - assert payload.source_link == "https://source.example" - - -@pytest.mark.asyncio -async def test_create_textmetada_payload_commentary_sets_group_and_category_from_version_group(): - service = TextMetadataService() - service.version_group_id = "group_v1" - service.commentary_group_id = "group_c1" - - text_metadata = { - "language": "bo", - "title": {"bo": "མཚན་བྱང་"}, - } - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="t_commentary" - ) - - with patch.object( - service, - "get_text_critical_instance", - new_callable=AsyncMock, - return_value=CriticalInstanceResponse( - critical_instances=[ - CriticalInstance( - id="inst_c1", - type="critical", - source="s", - alt_incipit_titles=None, - ) - ] - ), - ): - payload = await service.create_textmetada_payload( - text_id="t_commentary", - text_metadata=text_metadata, - type="commentary", - text_upload_request=text_upload_request - ) - - assert payload.group_id == "group_c1" - assert payload.categories == ["group_v1"] - assert payload.type == "commentary" - - -@pytest.mark.asyncio -async def test_get_uploaded_texts_returns_uploaded_text_ids_and_instances_mapping(): - service = TextMetadataService() - - async def _get_critical_instance_side_effect(text_id: str, openpecha_api_url: str): - instance_id = {"t1": "inst_1", "t2": "inst_2"}[text_id] - return CriticalInstanceResponse( - critical_instances=[ - CriticalInstance( - id=instance_id, - type="critical", - source="src", - alt_incipit_titles=None, - ) - ] - ) - - # Only inst_2 is already uploaded in the destination DB - uploaded_text_row = {"pecha_text_id": "inst_2", "group_id": "g"} - - with patch.object( - service, - "get_text_critical_instance", - new_callable=AsyncMock, - side_effect=_get_critical_instance_side_effect, - ), patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_texts_by_pecha_text_ids", - new_callable=AsyncMock, - return_value=[uploaded_text_row], - ): - texts, uploaded_text_ids, instances = await service.get_uploaded_texts( - text_ids=["t1", "t2"], - openpecha_api_url="https://openpecha.example", - destination_url="https://destination.example" - ) - - assert texts == [uploaded_text_row] - assert uploaded_text_ids == ["t2"] - assert instances == {"t1": "inst_1", "t2": "inst_2"} - - -@pytest.mark.asyncio -async def test_get_wb_collection_id_raises_when_collection_missing(): - service = TextMetadataService() - with patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value=None, - ): - with pytest.raises( - ValueError, match="Collection with pecha_collection_id pecha_missing not found" - ): - await service.get_wb_collection_id( - pecha_collection_id="pecha_missing", - destination_url="https://destination.example" - ) - - -@pytest.mark.asyncio -async def test_get_wb_collection_id_returns_collection_id(): - """Test get_wb_collection_id returns the collection ID when found""" - service = TextMetadataService() - - with patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_collection_by_pecha_collection_id", - new_callable=AsyncMock, - return_value="wb_collection_123" - ): - result = await service.get_wb_collection_id( - pecha_collection_id="pecha_collection_456", - destination_url="https://destination.example" - ) - - assert result == "wb_collection_123" - - -@pytest.mark.asyncio -async def test_upload_text_metadata_service_success(): - """Test upload_text_metadata_service with translation texts""" - service = TextMetadataService() - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="text_1" - ) - - text_metadata = { - "type": "translation", - "language": "en", - "title": {"en": "Test Text"} - } - - text_related_by_work = { - "work_1": { - "relation": "translation", - "expression_ids": ["text_2", "text_3"] - } - } - - with patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata", - new_callable=AsyncMock, - return_value=text_metadata - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_related_by_work", - new_callable=AsyncMock, - return_value=text_related_by_work - ), \ - patch.object( - service, - "get_text_meta_data_service", - new_callable=AsyncMock, - return_value={"new_text_id_1": "inst_1", "new_text_id_2": "inst_2"} - ) as mock_get_texts: - result = await service.upload_text_metadata_service( - text_upload_request=text_upload_request, - token="test_token" - ) - - assert isinstance(result, TextInstanceIds) - assert len(result.new_text) > 0 - # Verify the service was called for both translations and commentaries - assert mock_get_texts.call_count == 2 - # all_text reflects only what the real method accumulated; - # since get_text_meta_data_service is fully mocked here, all_instance_ids stays {} - assert result.all_text == {} - - -@pytest.mark.asyncio -async def test_upload_text_metadata_service_with_commentary(): - """Test upload_text_metadata_service with commentary texts""" - service = TextMetadataService() - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="commentary_1" - ) - - text_metadata = { - "type": "commentary", - "language": "bo", - "title": {"bo": "མཚན་བྱང་"} - } - - text_related_by_work = { - "work_1": { - "relation": "sibling_commentary", - "expression_ids": ["commentary_2"] - } - } - - with patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata", - new_callable=AsyncMock, - return_value=text_metadata - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_related_by_work", - new_callable=AsyncMock, - return_value=text_related_by_work - ), \ - patch.object( - service, - "get_text_meta_data_service", - new_callable=AsyncMock, - return_value={"commentary_id_1": "inst_c1"} - ): - result = await service.upload_text_metadata_service( - text_upload_request=text_upload_request, - token="test_token" - ) - - assert isinstance(result, TextInstanceIds) - assert result.new_text is not None - - -@pytest.mark.asyncio -async def test_get_text_meta_data_service_creates_group_for_first_text(): - """Test get_text_meta_data_service creates a new group for translations""" - service = TextMetadataService() - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="text_1" - ) - - text_metadata = { - "language": "en", - "title": {"en": "Test Text"}, - "category_id": "cat_1" - } - - critical_instance = CriticalInstanceResponse( - critical_instances=[ - CriticalInstance( - id="inst_1", - type="critical", - source="https://source.example", - alt_incipit_titles=None, - ) - ] - ) - - with patch.object( - service, - "get_uploaded_texts", - new_callable=AsyncMock, - return_value=([], [], {"text_1": "inst_1"}) - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata", - new_callable=AsyncMock, - return_value=text_metadata - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.post_group", - new_callable=AsyncMock, - return_value={"id": "new_group_id"} - ) as mock_post_group, \ - patch.object( - service, - "create_textmetada_payload", - new_callable=AsyncMock, - return_value=MagicMock() - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.post_text", - new_callable=AsyncMock, - return_value={"id": "new_text_id", "title": "Test Text"} - ): - result = await service.get_text_meta_data_service( - text_ids=["text_1"], - type="translation", - text_upload_request=text_upload_request, - token="test_token" - ) - - mock_post_group.assert_awaited_once() - assert service.version_group_id == "new_group_id" - assert "new_text_id" in result - assert service.all_instance_ids == {"text_1": "inst_1"} - - -@pytest.mark.asyncio -async def test_get_text_meta_data_service_skips_uploaded_texts(): - """Test get_text_meta_data_service skips already uploaded texts""" - service = TextMetadataService() - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="text_1" - ) - - text_metadata = { - "language": "en", - "title": {"en": "Already Uploaded Text"}, - "category_id": "cat_1" - } - - # Mock that text_1 is already uploaded - with patch.object( - service, - "get_uploaded_texts", - new_callable=AsyncMock, - return_value=( - [{"id": "existing_id", "pecha_text_id": "inst_1", "group_id": "existing_group_id"}], - ["text_1"], # text_1 is already uploaded - {"text_1": "inst_1"} - ) - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata", - new_callable=AsyncMock, - return_value=text_metadata - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.post_text", - new_callable=AsyncMock - ) as mock_post_text: - result = await service.get_text_meta_data_service( - text_ids=["text_1"], - type="translation", - text_upload_request=text_upload_request, - token="test_token" - ) - - # Should not post text since it's already uploaded - mock_post_text.assert_not_awaited() - assert result == {} - # Version group should be set from uploaded text - assert service.version_group_id == "existing_group_id" - assert service.all_instance_ids == {"text_1": "inst_1"} - - -@pytest.mark.asyncio -async def test_get_text_critical_instance_returns_critical_instances(): - """Test get_text_critical_instance returns CriticalInstanceResponse""" - service = TextMetadataService() - - expected_instances = [ - CriticalInstance( - id="inst_1", - type="critical", - source="https://source1.example", - alt_incipit_titles=None, - ), - CriticalInstance( - id="inst_2", - type="critical", - source="https://source2.example", - alt_incipit_titles=None, - ) - ] - - with patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_critical_instances", - new_callable=AsyncMock, - return_value=CriticalInstanceResponse(critical_instances=expected_instances) - ): - result = await service.get_text_critical_instance( - text_id="text_1", - openpecha_api_url="https://openpecha.example" - ) - - assert isinstance(result, CriticalInstanceResponse) - assert len(result.critical_instances) == 2 - assert result.critical_instances[0].id == "inst_1" - - -@pytest.mark.asyncio -async def test_get_text_meta_data_service_commentary_creates_separate_group(): - """Test get_text_meta_data_service creates separate group for commentary""" - service = TextMetadataService() - service.version_group_id = "version_group_123" - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="commentary_1" - ) - - text_metadata = { - "language": "bo", - "title": {"bo": "མཚན་བྱང་"}, - "category_id": "cat_1" - } - - with patch.object( - service, - "get_uploaded_texts", - new_callable=AsyncMock, - return_value=([], [], {"commentary_1": "inst_c1"}) - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata", - new_callable=AsyncMock, - return_value=text_metadata - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.post_group", - new_callable=AsyncMock, - return_value={"id": "commentary_group_id"} - ) as mock_post_group, \ - patch.object( - service, - "create_textmetada_payload", - new_callable=AsyncMock, - return_value=MagicMock() - ), \ - patch( - "pecha_api.text_uploader.text_metadata.text_metadata_service.post_text", - new_callable=AsyncMock, - return_value={"id": "commentary_text_id", "title": "Commentary"} - ): - result = await service.get_text_meta_data_service( - text_ids=["commentary_1"], - type="commentary", - text_upload_request=text_upload_request, - token="test_token" - ) - - # Should create commentary group - mock_post_group.assert_awaited_once_with('commentary', text_upload_request.destination_url, "test_token") - assert service.commentary_group_id == "commentary_group_id" - assert "commentary_text_id" in result - assert service.all_instance_ids == {"commentary_1": "inst_c1"} - - -@pytest.mark.asyncio -async def test_all_instance_ids_merges_translation_and_commentary_instances(): - """all_instance_ids must accumulate across both translation and commentary calls. - - The old bug used `=` (overwrite) instead of `.update()`, so only the last - batch's instances survived. With the fix, both batches are merged. - """ - service = TextMetadataService() - service.version_group_id = "version_group_123" - - text_upload_request = TextUploadRequest( - destination_url="https://destination.example", - openpecha_api_url="https://openpecha.example", - text_id="text_1" - ) - - translation_instances = {"text_1": "inst_t1", "text_2": "inst_t2"} - commentary_instances = {"commentary_1": "inst_c1"} - - translation_metadata = {"language": "en", "title": {"en": "T"}, "category_id": "cat_1"} - commentary_metadata = {"language": "bo", "title": {"bo": "C"}, "category_id": "cat_1"} - - # ── First call: translations ────────────────────────────────────────────── - with patch.object(service, "get_uploaded_texts", new_callable=AsyncMock, - return_value=([], [], translation_instances)), \ - patch("pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata", - new_callable=AsyncMock, return_value=translation_metadata), \ - patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_group", - new_callable=AsyncMock, return_value={"id": "grp_t"}), \ - patch.object(service, "create_textmetada_payload", - new_callable=AsyncMock, return_value=MagicMock()), \ - patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_text", - new_callable=AsyncMock, return_value={"id": "t_id", "title": "T"}): - await service.get_text_meta_data_service( - text_ids=["text_1", "text_2"], - type="translation", - text_upload_request=text_upload_request, - token="test_token" - ) - - assert service.all_instance_ids == translation_instances - - # ── Second call: commentaries ───────────────────────────────────────────── - with patch.object(service, "get_uploaded_texts", new_callable=AsyncMock, - return_value=([], [], commentary_instances)), \ - patch("pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata", - new_callable=AsyncMock, return_value=commentary_metadata), \ - patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_group", - new_callable=AsyncMock, return_value={"id": "grp_c"}), \ - patch.object(service, "create_textmetada_payload", - new_callable=AsyncMock, return_value=MagicMock()), \ - patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_text", - new_callable=AsyncMock, return_value={"id": "c_id", "title": "C"}): - await service.get_text_meta_data_service( - text_ids=["commentary_1"], - type="commentary", - text_upload_request=text_upload_request, - token="test_token" - ) - - # Both translation AND commentary instances must be present. - # The old `self.all_instance_ids = instances` (overwrite) would have left - # only {"commentary_1": "inst_c1"} here, missing the 2 translation entries. - expected = {**translation_instances, **commentary_instances} - assert service.all_instance_ids == expected - - diff --git a/tests/text_uploader/texts/test_toc_service.py b/tests/text_uploader/texts/test_toc_service.py deleted file mode 100644 index b48a3ff23..000000000 --- a/tests/text_uploader/texts/test_toc_service.py +++ /dev/null @@ -1,105 +0,0 @@ -import pytest -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -from pecha_api.text_uploader.table_of_content.toc_service import TocService - - -@pytest.mark.asyncio -async def test_order_segments_by_annotation_span_sorts_and_numbers(): - service = TocService() - annotation_segments = { - "data": [ - {"id": "s2", "span": {"start": 5, "end": 7}}, - {"id": "s1", "span": {"start": 0, "end": 2}}, - {"id": "s3", "span": {"start": 8, "end": 9}}, - ] - } - - result = await service.order_segments_by_annotation_span(annotation_segments) - - assert result == [ - {"segment_id": "s1", "segment_number": 1}, - {"segment_id": "s2", "segment_number": 2}, - {"segment_id": "s3", "segment_number": 3}, - ] - - -def test_create_toc_payload_uses_uuid_and_wraps_segments(): - service = TocService() - ordered_segments = [ - {"segment_id": "s1", "segment_number": 1}, - {"segment_id": "s2", "segment_number": 2}, - ] - - with patch( - "pecha_api.text_uploader.table_of_content.toc_service.uuid.uuid4", - return_value="fixed-uuid", - ): - payload = service.create_toc_payload(ordered_segments, text_id="t1") - - assert payload == { - "text_id": "t1", - "type": "text", - "sections": [ - { - "id": "fixed-uuid", - "title": "1", - "section_number": 1, - "segments": ordered_segments, - } - ], - } - - -@pytest.mark.asyncio -async def test_upload_toc_builds_payload_and_posts(): - service = TocService() - text_upload_request = SimpleNamespace( - destination_url="https://dest.example", - openpecha_api_url="https://openpecha.example", - text_id="ignored", - ) - - # Mocks for SegmentService instance methods used by TocService - service.segment_service.get_segments_annotation_by_pecha_text_id = AsyncMock( - return_value={"annotations": [{"type": "segmentation", "annotation_id": "ann_1"}]} - ) - # SegmentService.get_annotation_ids is synchronous. - service.segment_service.get_annotation_ids = lambda _instance: ["ann_1"] - service.segment_service.get_segments_by_id_list = AsyncMock( - return_value={ - "data": [ - {"id": "s2", "span": {"start": 2, "end": 3}}, - {"id": "s1", "span": {"start": 0, "end": 1}}, - ] - } - ) - - with patch( - "pecha_api.text_uploader.table_of_content.toc_service.uuid.uuid4", - return_value="fixed-uuid", - ), patch( - "pecha_api.text_uploader.table_of_content.toc_service.post_toc", - new_callable=AsyncMock, - return_value={"ok": True}, - ) as mock_post_toc: - await service.upload_toc( - text_ids={"local_text_1": "pecha_text_1"}, - text_upload_request=text_upload_request, - token="tok", - ) - - mock_post_toc.assert_awaited_once() - posted_payload, posted_destination, posted_token = mock_post_toc.await_args.args - - assert posted_destination == "https://dest.example" - assert posted_token == "tok" - assert posted_payload["text_id"] == "local_text_1" - assert posted_payload["sections"][0]["id"] == "fixed-uuid" - assert posted_payload["sections"][0]["segments"] == [ - {"segment_id": "s1", "segment_number": 1}, - {"segment_id": "s2", "segment_number": 2}, - ] - - diff --git a/tests/texts/mappings/__init__.py b/tests/texts/mappings/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/texts/mappings/test_delete_mappings.py b/tests/texts/mappings/test_delete_mappings.py deleted file mode 100644 index 3f9f3d5c4..000000000 --- a/tests/texts/mappings/test_delete_mappings.py +++ /dev/null @@ -1,157 +0,0 @@ -import json -import pytest -from unittest.mock import patch, MagicMock - -from fastapi.testclient import TestClient -from fastapi import HTTPException, status -from fastapi.routing import APIRoute - -from pecha_api.app import api -from pecha_api.texts.segments.segments_response_models import SegmentResponse, MappingResponse, SegmentDTO -from pecha_api.error_contants import ErrorConstants -from pecha_api.texts.mappings.mappings_response_models import TextMappingRequest - -client = TestClient(api) - -# Test data -text_mapping_delete_request = { - "text_mappings": [ - { - "text_id": "2ff4215e-bc9e-4d16-8d7e-b4adea3c6ef9", - "segment_id": "cce14575-ebc3-43aa-bcce-777676f3b2e2", - "mappings": [ - { - "parent_text_id": "e55d66bc-0b2c-4575-afe1-c357856b1592", - "segments": [ - "5bbe24b9-625e-41bf-b6aa-a949f26a7c05", - "83311e49-7e8b-413d-95c3-80d2cdea5158" - ] - } - ] - } - ] -} - -@pytest.mark.asyncio -@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping") -async def test_delete_text_mapping_success(mock_delete_mapping): - # Arrange - test_token = "test_token" - # The delete_segment_mapping function returns None for successful deletion - mock_delete_mapping.return_value = None - - # We need to directly test the endpoint handler function rather than using the TestClient - # for DELETE requests with a body - from pecha_api.texts.mappings.mappings_views import delete_text_mapping - - # Create a mock request object - request = TextMappingRequest(**text_mapping_delete_request) - auth_credentials = MagicMock() - auth_credentials.credentials = test_token - - # Call the endpoint handler directly - result = await delete_text_mapping(auth_credentials, request) - - # Assert - assert result is None - mock_delete_mapping.assert_called_once_with(token=test_token, text_mapping_request=request) - -def test_delete_text_mapping_unauthorized(): - # For unauthorized test, we can use the TestClient without a body - # since we're just testing the auth middleware - response = client.delete( - "/mappings" - ) - - # Assert - assert response.status_code == 403 - -@pytest.mark.asyncio -@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping") -async def test_delete_text_mapping_forbidden(mock_delete_mapping): - # Arrange - mock_delete_mapping.side_effect = HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ErrorConstants.ADMIN_ERROR_MESSAGE - ) - - # Test directly with the handler function - from pecha_api.texts.mappings.mappings_views import delete_text_mapping - - # Create a mock request object - request = TextMappingRequest(**text_mapping_delete_request) - auth_credentials = MagicMock() - auth_credentials.credentials = "token" - - # Act & Assert - with pytest.raises(HTTPException) as exc_info: - await delete_text_mapping(auth_credentials, request) - - # Verify the exception details - assert exc_info.value.status_code == 403 - assert exc_info.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE - -@pytest.mark.asyncio -@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping") -async def test_delete_text_mapping_not_found(mock_delete_mapping): - # Arrange - mock_delete_mapping.side_effect = HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Segment not found" - ) - - # Test directly with the handler function - from pecha_api.texts.mappings.mappings_views import delete_text_mapping - - # Create a mock request object - request = TextMappingRequest(**text_mapping_delete_request) - auth_credentials = MagicMock() - auth_credentials.credentials = "token" - - # Act & Assert - with pytest.raises(HTTPException) as exc_info: - await delete_text_mapping(auth_credentials, request) - - # Verify the exception details - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == "Segment not found" - -@pytest.mark.asyncio -@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping") -async def test_delete_text_mapping_bad_request(mock_delete_mapping): - # Arrange - mock_delete_mapping.side_effect = HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE - ) - - # Test directly with the handler function - from pecha_api.texts.mappings.mappings_views import delete_text_mapping - - # Create a mock request object - request = TextMappingRequest(**text_mapping_delete_request) - auth_credentials = MagicMock() - auth_credentials.credentials = "token" - - # Act & Assert - with pytest.raises(HTTPException) as exc_info: - await delete_text_mapping(auth_credentials, request) - - # Verify the exception details - assert exc_info.value.status_code == 400 - assert exc_info.value.detail == ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE - -def test_delete_text_mapping_invalid_request(): - # For validation errors, we can use a POST request to test the validation - # since the validation happens before the request reaches the handler - response = client.post( - "/mappings", - headers={ - "Authorization": "Bearer token", - "Content-Type": "application/json" - }, - json={"invalid": "request"} - ) - - # Assert - validation errors return 422 - assert response.status_code == 422 diff --git a/tests/texts/mappings/test_mapping_updates.py b/tests/texts/mappings/test_mapping_updates.py deleted file mode 100644 index 81338460d..000000000 --- a/tests/texts/mappings/test_mapping_updates.py +++ /dev/null @@ -1,164 +0,0 @@ -import uuid -import pytest -from typing import Dict, List -from unittest.mock import AsyncMock, Mock, patch - -from pecha_api.texts.mappings.mappings_service import ( - _merge_segment_mappings, - _get_existing_mappings, - _process_new_mappings, - _construct_update_segments -) -from pecha_api.texts.segments.segments_models import Mapping, Segment - -@pytest.fixture -def mock_segment(): - """Create a mock segment that doesn't require database initialization""" - def _create_segment(id_str: str, text_id: str, content: str, mapping: List[Mapping] = None): - segment = Mock(spec=Segment) - segment.id = uuid.UUID(id_str) if isinstance(id_str, str) else id_str - segment.text_id = text_id - segment.content = content - segment.mapping = mapping or [] - return segment - return _create_segment - - -def test_merge_segment_mappings(): - """Test merging of two segment mappings""" - # Arrange - existing_mapping = Mapping(text_id="text1", segments=["seg1", "seg2"]) - new_mapping = Mapping(text_id="text1", segments=["seg2", "seg3"]) - - # Act - result = _merge_segment_mappings(existing_mapping, new_mapping) - - # Assert - assert result.text_id == "text1" - assert set(result.segments) == {"seg1", "seg2", "seg3"} - assert result is existing_mapping # Should modify existing mapping - - -def test_get_existing_mappings(mock_segment): - """Test getting dictionary of existing mappings""" - # Arrange - mappings = [ - Mapping(text_id="text1", segments=["seg1"]), - Mapping(text_id="text2", segments=["seg2"]) - ] - segment = mock_segment( - id_str="12345678-1234-5678-1234-567812345678", - text_id="source", - content="content", - mapping=mappings - ) - - # Act - result = _get_existing_mappings(segment) - - # Assert - assert isinstance(result, dict) - assert len(result) == 2 - assert "text1" in result - assert "text2" in result - assert result["text1"].segments == ["seg1"] - assert result["text2"].segments == ["seg2"] - - -def test_get_existing_mappings_empty(mock_segment): - """Test getting mappings from segment with no mappings""" - # Arrange - segment = mock_segment( - id_str="12345678-1234-5678-1234-567812345678", - text_id="source", - content="content", - mapping=[] - ) - - # Act - result = _get_existing_mappings(segment) - - # Assert - assert isinstance(result, dict) - assert len(result) == 0 - - -def test_process_new_mappings_all_new(): - """Test processing entirely new mappings""" - # Arrange - new_mappings = [ - Mapping(text_id="text1", segments=["seg1"]), - Mapping(text_id="text2", segments=["seg2"]) - ] - existing_mappings: Dict[str, Mapping] = {} - - # Act - result = _process_new_mappings(new_mappings, existing_mappings) - - # Assert - assert len(result) == 2 - assert result == new_mappings - - -def test_process_new_mappings_merge(): - """Test processing mappings that need merging""" - # Arrange - existing_mappings = { - "text1": Mapping(text_id="text1", segments=["seg1", "seg2"]), - "text2": Mapping(text_id="text2", segments=["seg3"]) - } - new_mappings = [ - Mapping(text_id="text1", segments=["seg2", "seg4"]), - Mapping(text_id="text3", segments=["seg5"]) - ] - - # Act - result = _process_new_mappings(new_mappings, existing_mappings) - - # Assert - assert len(result) == 3 - text1_mapping = next(m for m in result if m.text_id == "text1") - assert set(text1_mapping.segments) == {"seg1", "seg2", "seg4"} - assert any(m.text_id == "text2" and m.segments == ["seg3"] for m in result) - assert any(m.text_id == "text3" and m.segments == ["seg5"] for m in result) - - -@pytest.mark.asyncio -async def test_construct_update_segments(mock_segment): - """Test construction of updated segments""" - # Arrange - segments = [ - mock_segment( - id_str="12345678-1234-5678-1234-567812345678", - text_id="source1", - content="content1", - mapping=[Mapping(text_id="text1", segments=["a", "b"])] - ), - mock_segment( - id_str="87654321-4321-8765-4321-876543210987", - text_id="source2", - content="content2", - mapping=[Mapping(text_id="text2", segments=["c"])] - ) - ] - update_dict = { - "12345678-1234-5678-1234-567812345678": [ - Mapping(text_id="text1", segments=["b", "d"]), - Mapping(text_id="text3", segments=["e"]) - ] - } - - # Act - result = await _construct_update_segments(segments, update_dict) - - # Assert - assert len(result) == 1 - updated_segment = result[0] - assert str(updated_segment.id) == "12345678-1234-5678-1234-567812345678" - assert len(updated_segment.mapping) == 2 - - text1_mapping = next(m for m in updated_segment.mapping if m.text_id == "text1") - assert set(text1_mapping.segments) == {"a", "b", "d"} - - text3_mapping = next(m for m in updated_segment.mapping if m.text_id == "text3") - assert text3_mapping.segments == ["e"] diff --git a/tests/texts/mappings/test_mappings_service.py b/tests/texts/mappings/test_mappings_service.py deleted file mode 100644 index 4fe0b5db8..000000000 --- a/tests/texts/mappings/test_mappings_service.py +++ /dev/null @@ -1,199 +0,0 @@ -from unittest.mock import AsyncMock, patch -import pytest -from fastapi import HTTPException, status -import uuid - -from pecha_api.error_contants import ErrorConstants -from pecha_api.texts.mappings.mappings_response_models import TextMappingRequest, MappingsModel, TextMapping -from pecha_api.texts.mappings.mappings_service import update_segment_mapping -from pecha_api.texts.segments.segments_models import Mapping -from pecha_api.texts.segments.segments_response_models import SegmentResponse -from pecha_api.texts.segments.segments_enum import SegmentType - -@pytest.mark.asyncio -async def test_update_segment_mapping_non_admin(): - """Test update fails when user is not admin""" - # Arrange - mapping_request = TextMappingRequest( - text_mappings=[ - TextMapping( - text_id="text-1", - segment_id="segment-1", - mappings=[MappingsModel(parent_text_id="parent-1", segments=["seg-1"])] - ) - ] - ) - - with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=False): - # Act & Assert - with pytest.raises(HTTPException) as exc_info: - await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token") - - assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN - assert exc_info.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE - - -@pytest.mark.asyncio -async def test_update_segment_mapping_invalid_text(): - """Test update fails when text ID doesn't exist""" - # Arrange - text_id = "invalid-text" - segment_id = "f7e14876-a3af-4652-8c84-8df2c046a105" - parent_text_id = "c87aae38-ea7a-4d2b-ba0e-fd7dc61e68d1" - - mapping_request = TextMappingRequest( - text_mappings=[ - TextMapping( - text_id=text_id, - segment_id=segment_id, - mappings=[MappingsModel(parent_text_id=parent_text_id, segments=["seg-1"])] - ) - ] - ) - - # Create mock objects for valid IDs only (text_id is invalid) - mock_parent_text = AsyncMock() - mock_parent_text.id = uuid.UUID(parent_text_id) - mock_parent_text.pecha_text_id = parent_text_id - - mock_segment_obj = AsyncMock() - mock_segment_obj.id = uuid.UUID(segment_id) - mock_segment_obj.pecha_segment_id = segment_id - - with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \ - patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \ - patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments: - - # Return only valid texts (missing text_id) - mock_get_texts.return_value = [mock_parent_text] - mock_get_segments.return_value = [mock_segment_obj] - - # Act & Assert - with pytest.raises(KeyError): - await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token") - - -@pytest.mark.asyncio -async def test_update_segment_mapping_invalid_segment(): - """Test update fails when segment ID doesn't exist""" - # Arrange - text_id = "8749b360-a55e-441c-b541-f7c6ba2f3c61" - segment_id = "invalid-segment" - parent_text_id = "c87aae38-ea7a-4d2b-ba0e-fd7dc61e68d1" - - mapping_request = TextMappingRequest( - text_mappings=[ - TextMapping( - text_id=text_id, - segment_id=segment_id, - mappings=[MappingsModel(parent_text_id=parent_text_id, segments=["seg-1"])] - ) - ] - ) - - # Create mock text objects - mock_text = AsyncMock() - mock_text.id = uuid.UUID(text_id) - mock_text.pecha_text_id = text_id - - mock_parent_text = AsyncMock() - mock_parent_text.id = uuid.UUID(parent_text_id) - mock_parent_text.pecha_text_id = parent_text_id - - with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \ - patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \ - patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments: - - # Return texts but no segments (segment_id is invalid) - mock_get_texts.return_value = [mock_text, mock_parent_text] - mock_get_segments.return_value = [] - - # Act & Assert - with pytest.raises(KeyError): - await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token") - - -@pytest.mark.asyncio -async def test_update_segment_mapping_invalid_parent_text(): - """Test update fails when parent text ID doesn't exist""" - # Arrange - text_id = "8749b360-a55e-441c-b541-f7c6ba2f3c61" - segment_id = "f7e14876-a3af-4652-8c84-8df2c046a105" - invalid_parent_text_id = "invalid-parent" - - mapping_request = TextMappingRequest( - text_mappings=[ - TextMapping( - text_id=text_id, - segment_id=segment_id, - mappings=[MappingsModel(parent_text_id=invalid_parent_text_id, segments=["seg-1"])] - ) - ] - ) - - # Create mock text object - mock_text = AsyncMock() - mock_text.id = uuid.UUID(text_id) - mock_text.pecha_text_id = text_id - - # Create mock segment object - mock_segment_obj = AsyncMock() - mock_segment_obj.id = uuid.UUID(segment_id) - mock_segment_obj.pecha_segment_id = segment_id - - with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \ - patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \ - patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments: - - # Return only valid text (missing parent_text_id) - mock_get_texts.return_value = [mock_text] - mock_get_segments.return_value = [mock_segment_obj] - - # Act & Assert - with pytest.raises(KeyError): - await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token") - - -@pytest.mark.asyncio -async def test_update_segment_mapping_invalid_parent_segment(): - """Test update fails when parent segment ID doesn't exist""" - # Arrange - text_id = "8749b360-a55e-441c-b541-f7c6ba2f3c61" - segment_id = "f7e14876-a3af-4652-8c84-8df2c046a105" - parent_text_id = "c87aae38-ea7a-4d2b-ba0e-fd7dc61e68d1" - invalid_parent_segment = "invalid-seg" - - mapping_request = TextMappingRequest( - text_mappings=[ - TextMapping( - text_id=text_id, - segment_id=segment_id, - mappings=[MappingsModel(parent_text_id=parent_text_id, segments=[invalid_parent_segment])] - )] - ) - - # Create mock text objects - mock_text = AsyncMock() - mock_text.id = uuid.UUID(text_id) - mock_text.pecha_text_id = text_id - - mock_parent_text = AsyncMock() - mock_parent_text.id = uuid.UUID(parent_text_id) - mock_parent_text.pecha_text_id = parent_text_id - - # Create mock segment object - mock_segment_obj = AsyncMock() - mock_segment_obj.id = uuid.UUID(segment_id) - mock_segment_obj.pecha_segment_id = segment_id - - with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \ - patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \ - patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments: - - # Return texts and segment but not parent segment (invalid_parent_segment is missing) - mock_get_texts.return_value = [mock_text, mock_parent_text] - mock_get_segments.return_value = [mock_segment_obj] - - # Act & Assert - with pytest.raises(KeyError): - await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token") diff --git a/tests/texts/mappings/test_mappings_views.py b/tests/texts/mappings/test_mappings_views.py deleted file mode 100644 index b711a2d66..000000000 --- a/tests/texts/mappings/test_mappings_views.py +++ /dev/null @@ -1,142 +0,0 @@ -from unittest.mock import patch - -from fastapi.testclient import TestClient -from pecha_api.app import api -from fastapi import HTTPException - -from pecha_api.texts.segments.segments_response_models import SegmentResponse, MappingResponse,SegmentDTO -from pecha_api.texts.segments.segments_enum import SegmentType - -client = TestClient(api) - -text_mapping_request = { - "text_mappings": [ - { - "text_id": "2ff4215e-bc9e-4d16-8d7e-b4adea3c6ef9", - "segment_id": "cce14575-ebc3-43aa-bcce-777676f3b2e2", - "mappings": [ - { - "parent_text_id": "e55d66bc-0b2c-4575-afe1-c357856b1592", - "segments": [ - "5bbe24b9-625e-41bf-b6aa-a949f26a7c05", - "83311e49-7e8b-413d-95c3-80d2cdea5158" - ] - } - ] - } - ] -} - - -@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping") -def test_create_text_mapping(mock_update_mapping): - # Arrange - test_token = "test_token" - mapping_response = MappingResponse( - text_id="e55d66bc-0b2c-4575-afe1-c357856b1592", - segments=[ - "5bbe24b9-625e-41bf-b6aa-a949f26a7c05", - "83311e49-7e8b-413d-95c3-80d2cdea5158" - ] - ) - section_response = SegmentResponse( - segments=[ - SegmentDTO( - id="cce14575-ebc3-43aa-bcce-777676f3b2e2", - text_id="2ff4215e-bc9e-4d16-8d7e-b4adea3c6ef9", - content="content pf the segment", - type=SegmentType.SOURCE, - mapping=[mapping_response] - - ) - ] - ) - mock_update_mapping.return_value = section_response - response = client.post( - "/mappings", - headers={"Authorization": f"Bearer {test_token}"}, - json=text_mapping_request - ) - - # Assert - assert response.status_code == 201 - assert response.json() == section_response.model_dump(mode="json") - - -def test_create_text_mapping_403(): - # Act - response = client.post( - "/mappings", - json={"text_id": "123"} - ) - - # Assert - assert response.status_code == 403 - - -@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping") -def test_create_text_mapping_404_text_not_found(mock_update_mapping): - mock_update_mapping.side_effect = HTTPException( - status_code=404, - detail="Text not found" - ) - - response = client.post( - "/mappings", - headers={"Authorization": "Bearer token"}, - json=text_mapping_request - ) - - assert response.status_code == 404 - assert response.json()["detail"] == "Text not found" - - -@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping") -def test_create_text_mapping_404_segment_not_found(mock_update_mapping): - mock_update_mapping.side_effect = HTTPException( - status_code=404, - detail="Segment not found" - ) - - response = client.post( - "/mappings", - headers={"Authorization": "Bearer token"}, - json=text_mapping_request - ) - - assert response.status_code == 404 - assert response.json()["detail"] == "Segment not found" - - -@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping") -def test_create_text_mapping_404_parent_text_not_found(mock_update_mapping): - mock_update_mapping.side_effect = HTTPException( - status_code=404, - detail="Parent text not found" - ) - - response = client.post( - "/mappings", - headers={"Authorization": "Bearer token"}, - json=text_mapping_request - ) - - assert response.status_code == 404 - assert response.json()["detail"] == "Parent text not found" - - -@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping") -def test_create_text_mapping_404_parent_segment_not_found(mock_update_mapping): - mock_update_mapping.side_effect = HTTPException( - status_code=404, - detail="Parent segment not found" - ) - - response = client.post( - "/mappings", - headers={"Authorization": "Bearer token"}, - json=text_mapping_request - ) - - assert response.status_code == 404 - assert response.json()["detail"] == "Parent segment not found" diff --git a/tests/texts/test_text_cache_service.py b/tests/texts/test_text_cache_service.py index b1de68d62..d5d73ea8b 100644 --- a/tests/texts/test_text_cache_service.py +++ b/tests/texts/test_text_cache_service.py @@ -12,9 +12,6 @@ set_text_versions_by_group_id_cache, update_text_details_cache, invalidate_text_cache_on_update, - get_table_of_content_by_sheet_id_cache, - set_table_of_content_by_sheet_id_cache, - delete_table_of_content_by_sheet_id_cache, set_text_details_by_id_cache, get_text_details_by_id_cache, delete_text_details_by_id_cache @@ -683,67 +680,6 @@ async def test_get_text_versions_by_group_id_cache_with_dict_response(): assert response is not None assert isinstance(response, TextVersionResponse) -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_cache_empty_cache(): - #Test get_table_of_content_by_sheet_id_cache with empty cache# - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None): - response = await get_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT) - - assert response is None - -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_cache_with_data(): - #Test get_table_of_content_by_sheet_id_cache with cached data# - mock_cache_data = TableOfContent( - id="table_of_content_id", - text_id="text_id_1", - type=TableOfContentType.SHEET, - sections=[] - ) - - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data): - response = await get_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT) - - assert response is not None - assert isinstance(response, TableOfContent) - assert response.id == "table_of_content_id" - -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_cache_with_dict_response(): - #Test get_table_of_content_by_sheet_id_cache when cache returns dict and needs conversion# - mock_cache_dict = { - "id": "table_of_content_id", - "text_id": "text_id_1", - "type": "sheet", - "sections": [] - } - - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict): - response = await get_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT) - - assert response is not None - assert isinstance(response, TableOfContent) - assert response.id == "table_of_content_id" - -@pytest.mark.asyncio -async def test_set_table_of_content_by_sheet_id_cache_success(): - #Test set_table_of_content_by_sheet_id_cache success# - mock_cache_data = TableOfContent( - id="table_of_content_id", - text_id="text_id_1", - type=TableOfContentType.SHEET, - sections=[] - ) - - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - await set_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT, data=mock_cache_data) - -@pytest.mark.asyncio -async def test_delete_table_of_content_by_sheet_id_cache_success(): - #Test delete_table_of_content_by_sheet_id_cache success# - with patch("pecha_api.texts.texts_cache_service.clear_cache", new_callable=AsyncMock): - await delete_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT) - @pytest.mark.asyncio async def test_set_text_details_by_id_cache_success(): #Test set_text_details_by_id_cache success# @@ -828,112 +764,3 @@ async def test_delete_text_details_by_id_cache_success(): #Test delete_text_details_by_id_cache success# with patch("pecha_api.texts.texts_cache_service.clear_cache", new_callable=AsyncMock): await delete_text_details_by_id_cache(text_id="text_id", cache_type=CacheType.TEXT_DETAIL) - -@pytest.mark.asyncio -async def test_update_text_details_cache_for_sheet_success(): - #Test update_text_details_cache for sheet with successful table of content update# - updated_text_data = TextDTO( - id="sheet_id_1", - title="Updated Sheet Title", - language="en", - group_id="group_id_1", - type="sheet", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 05:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="user_1", - categories=["category_1"], - views=10 - ) - - mock_table_of_content = TableOfContent( - id="toc_id", - text_id="sheet_id_1", - type=TableOfContentType.SHEET, - sections=[] - ) - - with patch("pecha_api.texts.texts_cache_service.update_cache", new_callable=AsyncMock, return_value=True) as mock_update_cache, \ - patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"), \ - patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content): - - result = await update_text_details_cache(text_id="sheet_id_1", updated_text_data=updated_text_data, cache_type=CacheType.SHEET_DETAIL) - - assert result is True - assert mock_update_cache.call_count == 3 # Called for primary cache, texts_by_id cache, and table of content cache - -@pytest.mark.asyncio -async def test_update_text_details_cache_for_sheet_with_toc_exception(): - #Test update_text_details_cache for sheet when table of content update raises exception# - updated_text_data = TextDTO( - id="sheet_id_1", - title="Updated Sheet Title", - language="en", - group_id="group_id_1", - type="sheet", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 05:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="user_1", - categories=["category_1"], - views=10 - ) - - with patch("pecha_api.texts.texts_cache_service.update_cache", new_callable=AsyncMock, return_value=True) as mock_update_cache, \ - patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"), \ - patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, side_effect=Exception("TOC error")), \ - patch("pecha_api.texts.texts_cache_service.logging.warning") as mock_log_warning: - - result = await update_text_details_cache(text_id="sheet_id_1", updated_text_data=updated_text_data, cache_type=CacheType.SHEET_DETAIL) - - assert result is True - assert mock_update_cache.call_count == 2 # Called for primary cache and texts_by_id cache only - mock_log_warning.assert_called_once() - -@pytest.mark.asyncio -async def test_update_text_details_cache_for_sheet_with_none_toc(): - #Test update_text_details_cache for sheet when table of content returns None# - updated_text_data = TextDTO( - id="sheet_id_1", - title="Updated Sheet Title", - language="en", - group_id="group_id_1", - type="sheet", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 05:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="user_1", - categories=["category_1"], - views=10 - ) - - with patch("pecha_api.texts.texts_cache_service.update_cache", new_callable=AsyncMock, return_value=True) as mock_update_cache, \ - patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"), \ - patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None): - - result = await update_text_details_cache(text_id="sheet_id_1", updated_text_data=updated_text_data, cache_type=CacheType.SHEET_DETAIL) - - assert result is True - assert mock_update_cache.call_count == 2 # Called for primary cache and texts_by_id cache only - -@pytest.mark.asyncio -async def test_invalidate_text_cache_on_update_for_sheet(): - #Test invalidate_text_cache_on_update for sheet with sheet-specific cache types# - with patch("pecha_api.texts.texts_cache_service.invalidate_multiple_cache_keys", new_callable=AsyncMock) as mock_invalidate_multiple, \ - patch("pecha_api.texts.texts_cache_service.invalidate_text_related_cache", new_callable=AsyncMock) as mock_invalidate_text, \ - patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"): - - result = await invalidate_text_cache_on_update(text_id="sheet_id_1", cache_type=CacheType.SHEET_DETAIL) - - assert result is True - mock_invalidate_multiple.assert_called_once() - mock_invalidate_text.assert_called_once_with(text_id="sheet_id_1") - - # Verify that hash keys are generated for sheet-specific cache types - args, kwargs = mock_invalidate_multiple.call_args - assert "hash_keys" in kwargs - assert len(kwargs["hash_keys"]) == 4 # Four different cache types for sheets - diff --git a/tests/texts/test_text_repository.py b/tests/texts/test_text_repository.py deleted file mode 100644 index 090bfec49..000000000 --- a/tests/texts/test_text_repository.py +++ /dev/null @@ -1,261 +0,0 @@ -from unittest.mock import AsyncMock, patch, MagicMock -import pytest -from uuid import uuid4 - -from pecha_api.texts.texts_repository import fetch_sheets_from_db -from pecha_api.texts.texts_models import Text -from pecha_api.texts.texts_enums import TextType -from pecha_api.sheets.sheets_enum import SortBy, SortOrder - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_default_parameters(): - """Test fetch_sheets_from_db with default parameters""" - # Mock Text objects - mock_text_1 = MagicMock() - mock_text_1.id = str(uuid4()) - mock_text_1.title = "Test Sheet 1" - mock_text_1.language = "bo" - mock_text_1.group_id = "group_1" - mock_text_1.type = TextType.SHEET - mock_text_1.is_published = True - mock_text_1.created_date = "2024-01-01 10:00:00" - mock_text_1.updated_date = "2024-01-01 10:00:00" - mock_text_1.published_date = "2024-01-01 10:00:00" - mock_text_1.published_by = "test@example.com" - mock_text_1.categories = [] - mock_text_1.views = 100 - - mock_text_2 = MagicMock() - mock_text_2.id = str(uuid4()) - mock_text_2.title = "Test Sheet 2" - mock_text_2.language = "en" - mock_text_2.group_id = "group_2" - mock_text_2.type = TextType.SHEET - mock_text_2.is_published = True - mock_text_2.created_date = "2024-01-02 15:30:00" - mock_text_2.updated_date = "2024-01-02 15:30:00" - mock_text_2.published_date = "2024-01-02 15:30:00" - mock_text_2.published_by = "another@example.com" - mock_text_2.categories = [] - mock_text_2.views = 250 - - mock_sheets = [mock_text_1, mock_text_2] - - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets: - result = await fetch_sheets_from_db() - - # Verify the result - assert result is not None - assert isinstance(result, list) - assert len(result) == 2 - assert result == mock_sheets - - # Verify Text.get_sheets was called with correct default parameters - mock_get_sheets.assert_called_once_with( - published_by=None, - is_published=None, - sort_by=None, - sort_order=None, - skip=0, - limit=10 - ) - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_with_all_parameters(): - """Test fetch_sheets_from_db with all parameters specified""" - mock_sheets = [] - - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets: - result = await fetch_sheets_from_db( - published_by="test@example.com", - is_published=True, - sort_by=SortBy.PUBLISHED_DATE, - sort_order=SortOrder.DESC, - skip=20, - limit=5 - ) - - # Verify the result - assert result is not None - assert isinstance(result, list) - assert len(result) == 0 - assert result == mock_sheets - - # Verify Text.get_sheets was called with correct parameters - mock_get_sheets.assert_called_once_with( - published_by="test@example.com", - is_published=True, - sort_by=SortBy.PUBLISHED_DATE, - sort_order=SortOrder.DESC, - skip=20, - limit=5 - ) - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_with_partial_parameters(): - """Test fetch_sheets_from_db with partial parameters""" - mock_text = MagicMock() - mock_text.id = str(uuid4()) - mock_text.title = "Filtered Sheet" - mock_text.language = "bo" - mock_text.group_id = "group_1" - mock_text.type = TextType.SHEET - mock_text.is_published = False - mock_text.created_date = "2024-01-01 10:00:00" - mock_text.updated_date = "2024-01-01 10:00:00" - mock_text.published_date = "2024-01-01 10:00:00" - mock_text.published_by = "specific@example.com" - mock_text.categories = [] - mock_text.views = 50 - - mock_sheets = [mock_text] - - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets: - result = await fetch_sheets_from_db( - published_by="specific@example.com", - is_published=False, - skip=5 - ) - - # Verify the result - assert result is not None - assert isinstance(result, list) - assert len(result) == 1 - assert result[0] == mock_text - - # Verify Text.get_sheets was called with correct parameters - mock_get_sheets.assert_called_once_with( - published_by="specific@example.com", - is_published=False, - sort_by=None, - sort_order=None, - skip=5, - limit=10 - ) - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_empty_result(): - """Test fetch_sheets_from_db when no sheets are found""" - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=[]) as mock_get_sheets: - result = await fetch_sheets_from_db() - - # Verify the result - assert result is not None - assert isinstance(result, list) - assert len(result) == 0 - - # Verify Text.get_sheets was called - mock_get_sheets.assert_called_once() - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_with_sort_by_created_date(): - """Test fetch_sheets_from_db with sort by created date""" - mock_sheets = [] - - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets: - result = await fetch_sheets_from_db( - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.ASC - ) - - # Verify the result - assert result is not None - assert isinstance(result, list) - - # Verify Text.get_sheets was called with correct sorting parameters - mock_get_sheets.assert_called_once_with( - published_by=None, - is_published=None, - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.ASC, - skip=0, - limit=10 - ) - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_large_skip_and_limit(): - """Test fetch_sheets_from_db with large skip and limit values""" - mock_sheets = [] - - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets: - result = await fetch_sheets_from_db(skip=100, limit=50) - - # Verify the result - assert result is not None - assert isinstance(result, list) - - # Verify Text.get_sheets was called with correct pagination parameters - mock_get_sheets.assert_called_once_with( - published_by=None, - is_published=None, - sort_by=None, - sort_order=None, - skip=100, - limit=50 - ) - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_published_only(): - """Test fetch_sheets_from_db filtering for published sheets only""" - mock_text = MagicMock() - mock_text.id = str(uuid4()) - mock_text.title = "Published Sheet" - mock_text.is_published = True - mock_text.type = TextType.SHEET - - mock_sheets = [mock_text] - - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets: - result = await fetch_sheets_from_db(is_published=True) - - # Verify the result - assert result is not None - assert isinstance(result, list) - assert len(result) == 1 - - # Verify Text.get_sheets was called with is_published=True - mock_get_sheets.assert_called_once_with( - published_by=None, - is_published=True, - sort_by=None, - sort_order=None, - skip=0, - limit=10 - ) - - -@pytest.mark.asyncio -async def test_fetch_sheets_from_db_unpublished_only(): - """Test fetch_sheets_from_db filtering for unpublished sheets only""" - mock_text = MagicMock() - mock_text.id = str(uuid4()) - mock_text.title = "Draft Sheet" - mock_text.is_published = False - mock_text.type = TextType.SHEET - - mock_sheets = [mock_text] - - with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets: - result = await fetch_sheets_from_db(is_published=False) - - # Verify the result - assert result is not None - assert isinstance(result, list) - assert len(result) == 1 - - # Verify Text.get_sheets was called with is_published=False - mock_get_sheets.assert_called_once_with( - published_by=None, - is_published=False, - sort_by=None, - sort_order=None, - skip=0, - limit=10 - ) \ No newline at end of file diff --git a/tests/texts/test_texts_service.py b/tests/texts/test_texts_service.py index 643d17a42..d8097484d 100644 --- a/tests/texts/test_texts_service.py +++ b/tests/texts/test_texts_service.py @@ -15,8 +15,6 @@ update_text_details, remove_table_of_content_by_text_id, delete_text_by_text_id, - get_sheet, - get_table_of_content_by_sheet_id, get_table_of_content_by_type, _validate_text_detail_request, get_root_text_by_collection_id, @@ -57,7 +55,6 @@ from pecha_api.recitations.recitations_response_models import RecitationDTO, RecitationsResponse from pecha_api.texts.texts_enums import TextType, PaginationDirection, LANGUAGE_ORDERS -from pecha_api.sheets.sheets_enum import SortBy, SortOrder from pecha_api.error_contants import ErrorConstants from typing import List @@ -138,13 +135,9 @@ async def test_get_text_by_collection_id(): ] with patch('pecha_api.texts.texts_service.get_collection', new_callable=AsyncMock, return_value=mock_collection), \ - patch('pecha_api.texts.texts_service.get_texts_by_collection', new_callable=AsyncMock) as mock_get_texts_by_category, \ patch('pecha_api.texts.texts_service.get_all_texts_by_collection', new_callable=AsyncMock) as mock_get_all_texts, \ patch('pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None), \ - patch('pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference', new_callable=AsyncMock) as mock_filter_text_base_on_group_id_type: - mock_filter_text_base_on_group_id_type.return_value = {"root_text": mock_texts_by_category[1], "commentary": [mock_texts_by_category[0]]} - mock_get_texts_by_category.return_value = mock_texts_by_category - # Return only the root text for total count calculation + patch('pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None): mock_get_all_texts.return_value = [mock_texts_by_category[1]] response = await get_text_by_text_id_or_collection(text_id=None, collection_id="id_1", language="bo", skip=0, limit=10) assert response is not None @@ -246,7 +239,6 @@ async def test_get_versions_by_group_id(): ) language = "en" with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \ - patch("pecha_api.texts.texts_service.get_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\ patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\ patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\ patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id: @@ -575,8 +567,6 @@ async def test_get_table_of_contents_by_text_id_success(): ] with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.get_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\ - patch("pecha_api.texts.texts_service.set_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\ patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \ patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents): @@ -681,8 +671,6 @@ async def test_get_table_of_contents_by_text_id_success_language_is_none(): ] with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.get_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\ - patch("pecha_api.texts.texts_service.set_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\ patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \ patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents): @@ -770,8 +758,6 @@ async def test_get_table_of_contents_by_text_id_root_text_is_none(): ] with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.get_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\ - patch("pecha_api.texts.texts_service.set_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\ patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \ patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents), \ @@ -870,35 +856,6 @@ async def test_delete_text_by_text_id_invalid_text_id(): assert exc_info.value.status_code == 404 assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE -@pytest.mark.asyncio -async def test_get_sheet_success_user_viewing_own_sheets(): - email = "test_user@gmail.com" - mock_sheets = [ - type("Text", (), { - "id": f"sheet_id_{i}", - "title": "Test Sheet", - "language": "en", - "group_id": "group_id", - "type": "sheet", - "is_published": True if i % 2 == 0 else False, - "created_date": "2021-01-01", - "updated_date": "2021-01-01", - "published_date": "2021-01-01", - "published_by": email, - "categories": [], - "views": 10 - })() - for i in range(1,11) - ] - with patch("pecha_api.texts.texts_service.fetch_sheets_from_db", new_callable=AsyncMock, return_value=mock_sheets): - - response = await get_sheet(published_by=email, is_published=None, sort_by=None, sort_order=None, skip=0, limit=10) - - assert response is not None - assert len(response) == 10 - for sheet in response: - assert sheet.published_by == email - @pytest.mark.asyncio async def test_get_text_details_by_text_id_with_text_id_content_id_segment_id_success(): text_id = "text_id_1" @@ -1358,56 +1315,6 @@ async def test_get_text_details_by_text_id_with_content_id_only_success(): assert response.pagination_direction == PaginationDirection.NEXT -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_success(): - sheet_id = "sheet_id_1" - mock_table_of_contents = [ - TableOfContent( - id="content_id_1", - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment( - segment_id="segment_id_1", - segment_number=1 - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - ] - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents), \ - patch("pecha_api.texts.texts_service.set_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=None): - - response = await get_table_of_content_by_sheet_id(sheet_id=sheet_id) - - assert response is not None - assert isinstance(response, TableOfContent) - assert response.id == "content_id_1" - -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_invalid_sheet_id(): - sheet_id = "invalid_sheet_id" - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_table_of_content_by_sheet_id(sheet_id=sheet_id) - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - - @pytest.mark.asyncio async def test_validate_text_detail_request_success(): text_id = "text_id_1" @@ -1591,7 +1498,6 @@ async def test_get_versions_by_group_id_language_is_none(): ) language = "en" with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \ - patch("pecha_api.texts.texts_service.get_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\ patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\ patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\ patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id: @@ -2789,55 +2695,6 @@ async def test_update_text_details_cache_update_fails(): # Verify that invalidate was called as fallback mock_invalidate.assert_called_once_with(text_id="text_id_1") -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_with_cache(): - """Test get_table_of_content_by_sheet_id when data is in cache""" - sheet_id = "sheet_id_1" - cached_toc = TableOfContent( - id="content_id_1", - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment( - segment_id="segment_id_1", - segment_number=1 - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - - with patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=cached_toc): - response = await get_table_of_content_by_sheet_id(sheet_id=sheet_id) - - assert response is not None - assert isinstance(response, TableOfContent) - assert response.id == "content_id_1" - -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_no_content(): - """Test get_table_of_content_by_sheet_id when no content exists""" - sheet_id = "sheet_id_1" - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]), \ - patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=None): - - response = await get_table_of_content_by_sheet_id(sheet_id=sheet_id) - - assert response is None - - @pytest.mark.asyncio async def test_get_text_by_text_id_or_collection_with_cache(): """Test get_text_by_text_id_or_collection fetches text (cache is currently disabled in code)""" @@ -2868,42 +2725,6 @@ async def test_get_text_by_text_id_or_collection_with_cache(): assert response.title == "Test Text" -@pytest.mark.asyncio -async def test_get_sheet_with_filters(): - """Test get_sheet with various filter parameters""" - mock_sheets = [ - TextDTO( - id="sheet_id_1", - title="Sheet 1", - language="bo", - group_id="group_id_1", - type="sheet", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="user_1", - categories=[], - views=10 - ) - ] - - with patch("pecha_api.texts.texts_service.fetch_sheets_from_db", new_callable=AsyncMock, return_value=mock_sheets): - result = await get_sheet( - published_by="user_1", - is_published=True, - sort_by=SortBy.CREATED_DATE, - sort_order=SortOrder.DESC, - skip=0, - limit=10 - ) - - assert result is not None - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].id == "sheet_id_1" - - @pytest.mark.asyncio async def test_update_text_details_with_cache_invalidation(): """Test update_text_details updates text successfully""" @@ -2952,25 +2773,6 @@ async def test_remove_table_of_content_by_text_id_success(): mock_delete.assert_called_once_with(text_id=text_id) -@pytest.mark.asyncio -async def test_get_table_of_content_by_sheet_id_with_cache(): - """Test get_table_of_content_by_sheet_id returns cached data""" - sheet_id = "sheet_id_1" - cached_toc = TableOfContent( - id="toc_id_1", - text_id=sheet_id, - type=TableOfContentType.SHEET, - sections=[] - ) - - with patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=cached_toc): - result = await get_table_of_content_by_sheet_id(sheet_id=sheet_id) - - assert result is not None - assert isinstance(result, TableOfContent) - assert result.id == "toc_id_1" - - @pytest.mark.asyncio async def test_get_text_by_collection_id_empty_result(): """Test get_text_by_text_id_or_collection with collection_id returns empty when no texts""" diff --git a/tests/texts/test_texts_views.py b/tests/texts/test_texts_views.py index 99f0bb1d4..e03358fb5 100644 --- a/tests/texts/test_texts_views.py +++ b/tests/texts/test_texts_views.py @@ -833,298 +833,6 @@ async def test_create_text_service_error(mocker): assert response.json()["detail"] == "Invalid group_id" -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_success(mocker): - """Test POST /text-uploader/list with valid pecha_text_ids""" - mock_text_1 = TextDTO( - id="text-1-uuid", - pecha_text_id="pecha_text_id_1", - title="Test Text 1", - language="bo", - group_id="group-1-uuid", - type="root_text", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - categories=[], - views=10, - source_link="https://test-source-1.com", - ranking=1, - license="CC0" - ) - - mock_text_2 = TextDTO( - id="text-2-uuid", - pecha_text_id="pecha_text_id_2", - title="Test Text 2", - language="en", - group_id="group-2-uuid", - type="version", - is_published=True, - created_date="2025-01-02T00:00:00", - updated_date="2025-01-02T00:00:00", - published_date="2025-01-02T00:00:00", - published_by="test_user_2", - categories=["category-1"], - views=20, - source_link="https://test-source-2.com", - ranking=2, - license="CC BY" - ) - - mock_texts = [mock_text_1, mock_text_2] - - mock_get_texts = mocker.patch( - 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service', - new_callable=AsyncMock, - return_value=mock_texts - ) - - request_data = { - "pecha_text_ids": ["pecha_text_id_1", "pecha_text_id_2"] - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/text-uploader/list", - json=request_data - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 2 - assert data[0]["pecha_text_id"] == "pecha_text_id_1" - assert data[0]["title"] == "Test Text 1" - assert data[1]["pecha_text_id"] == "pecha_text_id_2" - assert data[1]["title"] == "Test Text 2" - - mock_get_texts.assert_called_once() - call_args = mock_get_texts.call_args[1] - assert isinstance(call_args["texts_by_pecha_text_ids_request"], TextsByPechaTextIdsRequest) - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_empty_list(mocker): - """Test POST /text-uploader/list with empty pecha_text_ids list""" - mock_get_texts = mocker.patch( - 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service', - new_callable=AsyncMock, - return_value=[] - ) - - request_data = { - "pecha_text_ids": [] - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/text-uploader/list", - json=request_data - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 0 - assert data == [] - - mock_get_texts.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_single_text(mocker): - """Test POST /text-uploader/list with single pecha_text_id""" - mock_text = TextDTO( - id="single-text-uuid", - pecha_text_id="pecha_text_id_single", - title="Single Test Text", - language="bo", - group_id="group-uuid", - type="root_text", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - categories=[], - views=5, - source_link="https://test-source.com", - ranking=1, - license="CC0" - ) - - mock_get_texts = mocker.patch( - 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service', - new_callable=AsyncMock, - return_value=[mock_text] - ) - - request_data = { - "pecha_text_ids": ["pecha_text_id_single"] - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/text-uploader/list", - json=request_data - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 1 - assert data[0]["pecha_text_id"] == "pecha_text_id_single" - assert data[0]["title"] == "Single Test Text" - - mock_get_texts.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_none_result(mocker): - """Test POST /text-uploader/list when service returns None""" - mock_get_texts = mocker.patch( - 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service', - new_callable=AsyncMock, - return_value=None - ) - - request_data = { - "pecha_text_ids": ["nonexistent_pecha_id"] - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/text-uploader/list", - json=request_data - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data is None - - mock_get_texts.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_invalid_request(): - """Test POST /text-uploader/list with invalid request data""" - invalid_data = { - "invalid_field": ["pecha_text_id_1"] - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/text-uploader/list", - json=invalid_data - ) - - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_service_error(mocker): - """Test POST /text-uploader/list when service raises an error""" - mock_get_texts = mocker.patch( - 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service', - new_callable=AsyncMock, - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - ) - ) - - request_data = { - "pecha_text_ids": ["pecha_text_id_1"] - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/text-uploader/list", - json=request_data - ) - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.json()["detail"] == "Internal server error" - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_multiple_texts_with_optional_fields(mocker): - """Test POST /text-uploader/list with texts having various optional fields""" - mock_text_with_all_fields = TextDTO( - id="text-all-fields-uuid", - pecha_text_id="pecha_text_id_all", - title="Text with All Fields", - language="bo", - group_id="group-uuid", - type="root_text", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - categories=["cat1", "cat2"], - views=100, - source_link="https://source.com", - ranking=5, - license="CC BY-SA" - ) - - mock_text_with_minimal_fields = TextDTO( - id="text-minimal-uuid", - pecha_text_id=None, - title="Text with Minimal Fields", - language="en", - group_id="group-uuid-2", - type="version", - is_published=False, - created_date="2025-01-02T00:00:00", - updated_date="2025-01-02T00:00:00", - published_date="2025-01-02T00:00:00", - published_by="test_user_2", - categories=None, - views=0, - source_link=None, - ranking=None, - license=None - ) - - mock_texts = [mock_text_with_all_fields, mock_text_with_minimal_fields] - - mock_get_texts = mocker.patch( - 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service', - new_callable=AsyncMock, - return_value=mock_texts - ) - - request_data = { - "pecha_text_ids": ["pecha_text_id_all", "pecha_text_id_minimal"] - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/text-uploader/list", - json=request_data - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 2 - - # Verify first text with all fields - assert data[0]["pecha_text_id"] == "pecha_text_id_all" - assert data[0]["categories"] == ["cat1", "cat2"] - assert data[0]["views"] == 100 - assert data[0]["ranking"] == 5 - - # Verify second text with minimal fields - assert data[1]["pecha_text_id"] is None - assert data[1]["categories"] is None - assert data[1]["views"] == 0 - assert data[1]["source_link"] is None - assert data[1]["ranking"] is None - assert data[1]["license"] is None - - @pytest.mark.asyncio async def test_search_titles_success(mocker): """Test GET /texts/title-search returns title search results""" From 0ace4737e9d346adebbbb4adc728226edf1d02ee Mon Sep 17 00:00:00 2001 From: Jajin <102473656+Lungsangg@users.noreply.github.com> Date: Thu, 28 May 2026 16:47:13 +0530 Subject: [PATCH 02/29] =?UTF-8?q?refactor:=20remove=20deprecated=20collect?= =?UTF-8?q?ions=20endpoints=20and=20unused=20function=E2=80=A6=20(#634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove deprecated collections endpoints and unused functionality * refactor: remove duplicate collection upload function and unused import --------- Co-authored-by: TenzDelek --- pecha_api/app.py | 2 - .../collections/collections_cache_service.py | 27 +- .../collections/collections_repository.py | 70 ---- .../collections_response_models.py | 13 +- pecha_api/collections/collections_service.py | 118 +----- pecha_api/collections/collections_views.py | 98 ----- tests/collection/test_collection_service.py | 352 ----------------- tests/collection/test_collection_views.py | 277 ------------- .../test_collections_cache_service.py | 367 ------------------ 9 files changed, 6 insertions(+), 1318 deletions(-) delete mode 100644 pecha_api/collections/collections_views.py delete mode 100644 tests/collection/test_collection_service.py delete mode 100644 tests/collection/test_collection_views.py delete mode 100644 tests/collection/test_collections_cache_service.py diff --git a/pecha_api/app.py b/pecha_api/app.py index 19cb940c1..1b4602e69 100644 --- a/pecha_api/app.py +++ b/pecha_api/app.py @@ -6,7 +6,6 @@ from pecha_api.db.mongo_database import lifespan from pecha_api.auth import auth_views -from pecha_api.collections import collections_views from pecha_api.terms import terms_views from pecha_api.texts import texts_views from pecha_api.topics import topics_views @@ -46,7 +45,6 @@ lifespan=lifespan ) api.include_router(auth_views.auth_router) -api.include_router(collections_views.collections_router) api.include_router(terms_views.terms_router) api.include_router(texts_views.text_router) api.include_router(groups_views.group_router) diff --git a/pecha_api/collections/collections_cache_service.py b/pecha_api/collections/collections_cache_service.py index 9b9303903..301195bab 100644 --- a/pecha_api/collections/collections_cache_service.py +++ b/pecha_api/collections/collections_cache_service.py @@ -3,30 +3,11 @@ from pecha_api.cache.cache_repository import ( get_cache_data, set_cache, - clear_cache, ) from pecha_api import config -from .collections_response_models import ( - CollectionsResponse, - CollectionModel -) +from .collections_response_models import CollectionModel from pecha_api.cache.cache_enums import CacheType -async def get_collections_cache(parent_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> CollectionsResponse: - """Get collections cache asynchronously.""" - payload = [parent_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload=payload) - cache_data: CollectionsResponse = await get_cache_data(hash_key=hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = CollectionsResponse(**cache_data) - return cache_data - -async def set_collections_cache(parent_id: str = None, language: str = None, skip: int = None, limit: int = None, data: CollectionsResponse = None, cache_type: CacheType = None): - """Set collections cache asynchronously.""" - payload = [parent_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload=payload) - cache_time_out = config.get_int("CACHE_COLLECTION_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) async def get_collection_detail_cache(collection_id: str = None, language: str = None, cache_type: CacheType = None) -> CollectionModel: """Get collection detail cache asynchronously.""" @@ -43,9 +24,3 @@ async def set_collection_detail_cache(collection_id: str = None, language: str = hashed_key: str = Utils.generate_hash_key(payload=payload) cache_time_out = config.get_int("CACHE_COLLECTION_TIMEOUT") await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def delete_collection_cache(collection_id: str = None, cache_type: CacheType = None): - """Delete collection cache asynchronously.""" - payload = [collection_id, cache_type] - hashed_key: str = Utils.generate_hash_key(payload=payload) - await clear_cache(hash_key=hashed_key) \ No newline at end of file diff --git a/pecha_api/collections/collections_repository.py b/pecha_api/collections/collections_repository.py index 2a556b4be..ffa6ad4fe 100644 --- a/pecha_api/collections/collections_repository.py +++ b/pecha_api/collections/collections_repository.py @@ -2,27 +2,10 @@ from typing import Optional from beanie.exceptions import CollectionWasNotInitialized -from fastapi import HTTPException -from starlette import status from pecha_api.utils import Utils from ..collections.collections_models import Collection -from ..collections.collections_response_models import CreateCollectionRequest, UpdateCollectionRequest -COLLECTION_NOT_FOUND = 'Collection not found' - - -async def get_collections_by_parent( - parent_id: Optional[str], - skip: int, - limit: int) -> list[Collection]: - try: - topic_parent_id = Utils.get_parent_id(parent_id=parent_id) - collections = await Collection.get_children_by_id(parent_id=topic_parent_id, skip=skip, limit=limit) - return collections - except CollectionWasNotInitialized as e: - logging.debug(e) - return [] async def get_all_collections_by_parent( parent_id: Optional[str]) -> list[Collection]: @@ -34,65 +17,12 @@ async def get_all_collections_by_parent( logging.debug(e) return [] -async def get_child_count(parent_id: Optional[str]) -> int: - topic_parent_id = Utils.get_parent_id(parent_id=parent_id) - count = await Collection.count_children(parent_id=topic_parent_id) - return count - - -async def create_collection(create_collection_request: CreateCollectionRequest) -> Collection: - try: - existing_collection = await Collection.find_one(Collection.slug == create_collection_request.slug) - if existing_collection: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Collection with this slug {create_collection_request.slug} already exists") - except AttributeError as e: - logging.debug(e) - new_collection = Collection(pecha_collection_id=create_collection_request.pecha_collection_id, slug=create_collection_request.slug, titles=create_collection_request.titles, - descriptions=create_collection_request.descriptions, parent_id=create_collection_request.parent_id) - saved_collection = await new_collection.insert() - if create_collection_request.parent_id is not None: - await update_collection_child_status(collection_id=create_collection_request.parent_id) - return saved_collection - - async def get_collection_by_id(collection_id: Optional[str]) -> Optional[Collection]: if not collection_id: return None return await Collection.get(collection_id) -async def update_collection_child_status(collection_id: str) -> Collection: - existing_collection = await Collection.get(collection_id) - if not existing_collection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=COLLECTION_NOT_FOUND) - child_count = await Collection.count_children(parent_id=existing_collection.id) - has_child = child_count > 0 - if existing_collection.has_sub_child != has_child: - existing_collection.has_sub_child = has_child - await existing_collection.save() - return existing_collection - - -async def update_collection_titles(collection_id: str, update_collection_request: UpdateCollectionRequest) -> Collection: - existing_collection = await Collection.get(collection_id) - if not existing_collection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=COLLECTION_NOT_FOUND) - - existing_collection.titles = update_collection_request.titles - await existing_collection.save() - return existing_collection - - -async def delete_collection(collection_id: str): - existing_collection = await Collection.get(collection_id) - if not existing_collection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=COLLECTION_NOT_FOUND) - parent_id = existing_collection.parent_id - await existing_collection.delete() - if parent_id is not None: - await update_collection_child_status(collection_id=str(parent_id)) - return existing_collection - async def get_collection_id_by_slug(slug: str) -> Optional[str]: collection = await Collection.get_by_slug(slug=slug) if collection: diff --git a/pecha_api/collections/collections_response_models.py b/pecha_api/collections/collections_response_models.py index 36400f8f2..936014737 100644 --- a/pecha_api/collections/collections_response_models.py +++ b/pecha_api/collections/collections_response_models.py @@ -1,19 +1,8 @@ -from typing import Dict, List, Optional +from typing import List, Optional from pydantic import BaseModel -class CreateCollectionRequest(BaseModel): - pecha_collection_id: Optional[str] = None - slug: str - titles: Dict[str, str] - descriptions: Dict[str, str] - parent_id: Optional[str] - -class UpdateCollectionRequest(BaseModel): - titles: Dict[str, str] - descriptions: Dict[str, str] - class CollectionModel(BaseModel): id: str pecha_collection_id: Optional[str] = None diff --git a/pecha_api/collections/collections_service.py b/pecha_api/collections/collections_service.py index 61edcfe7b..c32856bd7 100644 --- a/pecha_api/collections/collections_service.py +++ b/pecha_api/collections/collections_service.py @@ -1,99 +1,17 @@ from typing import Optional -from starlette import status -from beanie import PydanticObjectId - -from pecha_api.error_contants import ErrorConstants from pecha_api.utils import Utils from ..config import get -from ..collections.collections_response_models import CollectionModel, CollectionsResponse, Pagination, CreateCollectionRequest, UpdateCollectionRequest -from .collections_repository import get_child_count, get_collections_by_parent, create_collection, update_collection_titles, delete_collection, \ - get_collection_by_id, get_collection_id_by_pecha_collection_id +from ..collections.collections_response_models import CollectionModel +from .collections_repository import get_collection_by_id, get_collection_id_by_pecha_collection_id from .collections_cache_service import ( - get_collections_cache, - set_collections_cache, get_collection_detail_cache, - set_collection_detail_cache, - delete_collection_cache + set_collection_detail_cache ) from pecha_api.cache.cache_enums import CacheType -from ..users.users_service import verify_admin_access -from fastapi import HTTPException - - -async def get_all_collections(language: str, parent_id: Optional[PydanticObjectId], skip: int, limit: int) -> CollectionsResponse: - if language is None: - language = get("DEFAULT_LANGUAGE") - - # Try to get from cache first - cached_data = await get_collections_cache( - parent_id=parent_id, - language=language, - skip=skip, - limit=limit, - cache_type=CacheType.COLLECTIONS - ) - - if cached_data: - return cached_data - - # If not in cache, fetch from database - total = await get_child_count(parent_id=parent_id) - parent_collection = await get_collection(collection_id=parent_id,language=language) - collections = await get_collections_by_parent( - parent_id=parent_id, - skip=skip, - limit=limit - ) - collection_list = [ - CollectionModel( - id=str(collection.id), - pecha_collection_id=str(collection.pecha_collection_id), - title=Utils.get_value_from_dict(values=collection.titles, language=language), - description=Utils.get_value_from_dict(values=collection.descriptions, language=language), - has_child=collection.has_sub_child, - language=language, - slug=collection.slug - ) - for collection in collections - ] - pagination = Pagination(total=total, skip=skip, limit=limit) - - collection_response = CollectionsResponse(parent=parent_collection, pagination=pagination, collections=collection_list) - - # Cache the result - await set_collections_cache( - parent_id=parent_id, - language=language, - skip=skip, - limit=limit, - data=collection_response, - cache_type=CacheType.COLLECTIONS - ) - - return collection_response -async def create_new_collection(create_collection_request: CreateCollectionRequest, token: str, language: Optional[str]) -> CollectionModel: - is_admin = verify_admin_access(token=token) - if is_admin: - new_collection = await create_collection(create_collection_request=create_collection_request) - if language is None: - language = get("DEFAULT_LANGUAGE") - return CollectionModel( - id=str(new_collection.id), - pecha_collection_id=str(new_collection.pecha_collection_id), - title=Utils.get_value_from_dict(values=new_collection.titles, language=language), - description=Utils.get_value_from_dict(values=new_collection.descriptions, language=language), - has_child=new_collection.has_sub_child, - language=language, - slug=new_collection.slug - - ) - - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE) - -async def get_collection(collection_id: str,language: str) -> Optional[CollectionModel]: +async def get_collection(collection_id: str, language: str) -> Optional[CollectionModel]: if collection_id is None: return None @@ -131,34 +49,6 @@ async def get_collection(collection_id: str,language: str) -> Optional[Collectio return collection_model return None -async def update_existing_collection(collection_id: str, update_collection_request: UpdateCollectionRequest, token: str, - language: Optional[str]) -> CollectionModel: - is_admin = verify_admin_access(token=token) - if is_admin: - updated_collection = await update_collection_titles(collection_id=collection_id, update_collection_request=update_collection_request) - if language is None: - language = get("DEFAULT_LANGUAGE") - return CollectionModel( - id=collection_id, - pecha_collection_id=str(updated_collection.pecha_collection_id), - title=Utils.get_value_from_dict(values=updated_collection.titles, language=language), - description=Utils.get_value_from_dict(values=updated_collection.descriptions, language=language), - has_child=updated_collection.has_sub_child, - language=language, - slug=updated_collection.slug - ) - - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE) - - -async def delete_existing_collection(collection_id: str, token: str): - is_admin = verify_admin_access(token=token) - if is_admin: - await delete_collection(collection_id=collection_id) - return collection_id - - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE) - async def get_collection_by_pecha_collection_id_service(pecha_collection_id: str) -> Optional[str]: collection_id = await get_collection_id_by_pecha_collection_id(pecha_collection_id=pecha_collection_id) if collection_id: diff --git a/pecha_api/collections/collections_views.py b/pecha_api/collections/collections_views.py deleted file mode 100644 index 3bf729d24..000000000 --- a/pecha_api/collections/collections_views.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from fastapi import APIRouter, Depends, Query, Response -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from starlette import status -from typing import Annotated, Optional - -from ..collections.collections_response_models import CreateCollectionRequest, UpdateCollectionRequest -from ..collections.collections_service import ( - get_all_collections, - create_new_collection, - update_existing_collection, - delete_existing_collection, - get_collection_by_pecha_collection_id_service -) - -oauth2_scheme = HTTPBearer() -collections_router = APIRouter( - prefix="/collections", - tags=["collections"] -) - -DEPRECATION_DATE = "Sat, 01 Jun 2026 00:00:00 GMT" -V2_ENDPOINT = "/api/v2/collections" - - -@collections_router.get( - "", - status_code=status.HTTP_200_OK, - deprecated=True, - summary="Get collections (DEPRECATED)", - description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections." -) -async def read_collection( - response: Response, - parent_id: Optional[str] = Query(None, description="Filter topics by title prefix"), - language: Optional[str] = None, - skip: int = Query(default=0, ge=0, description="Number of records to skip"), - limit: int = Query(default=10, ge=1, le=100, description="Number of records to return") -): - response.headers["Deprecation"] = "true" - response.headers["Sunset"] = DEPRECATION_DATE - response.headers["Link"] = f"<{V2_ENDPOINT}>; rel=\"successor-version\"" - return await get_all_collections( - parent_id=parent_id, - language=language, - skip=skip, - limit=limit - ) - - -@collections_router.post( - "", - status_code=status.HTTP_201_CREATED, - deprecated=True, - summary="Create collection (DEPRECATED)", - description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections." -) -async def create_collection(language: str | None, create_collection_request: CreateCollectionRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)]): - return await create_new_collection( - create_collection_request=create_collection_request, - token=authentication_credential.credentials, - language=language - ) - - -@collections_router.put( - "/{collection_id}", - status_code=status.HTTP_202_ACCEPTED, - deprecated=True, - summary="Update collection (DEPRECATED)", - description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections." -) -async def update_collection_by_id(collection_id: str, language: str | None, update_collection_request: UpdateCollectionRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)]): - return await update_existing_collection( - collection_id=collection_id, - update_collection_request=update_collection_request, - token=authentication_credential.credentials, - language=language - ) - - -@collections_router.delete( - "/{collection_id}", - status_code=status.HTTP_204_NO_CONTENT, - deprecated=True, - summary="Delete collection (DEPRECATED)", - description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections." -) -async def delete_collection_by_id(collection_id: str, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)]): - return await delete_existing_collection( - collection_id=collection_id, - token=authentication_credential.credentials - ) - diff --git a/tests/collection/test_collection_service.py b/tests/collection/test_collection_service.py deleted file mode 100644 index 8e2f799b2..000000000 --- a/tests/collection/test_collection_service.py +++ /dev/null @@ -1,352 +0,0 @@ -import pytest -from unittest.mock import patch, AsyncMock - -from starlette import status - -from pecha_api.collections.collections_service import ( - get_all_collections, - create_new_collection, - update_existing_collection, - delete_existing_collection, - get_collection, - get_collection_by_pecha_collection_id_service -) -from pecha_api.collections.collections_response_models import CollectionModel, CollectionsResponse, CreateCollectionRequest, UpdateCollectionRequest -from fastapi import HTTPException - - -@pytest.mark.asyncio -async def test_get_all_collections(): - with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \ - patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.collections.collections_service.get_collections_by_parent", - new_callable=AsyncMock) as mock_get_collections_by_parent, \ - patch("pecha_api.collections.collections_service.get_child_count", new_callable=AsyncMock, return_value=2), \ - patch("pecha_api.collections.collections_service.set_collections_cache", new_callable=AsyncMock) as mock_set_collections_cache: - mock_get_collections_by_parent.return_value = [ - AsyncMock(id="id_1", pecha_collection_id="pecha_id_1", titles={"en": "Collection 1"}, slug="collection-1",parent_id=None, has_sub_child=False), - AsyncMock(id="id_2", pecha_collection_id="pecha_id_2", titles={"en": "Collection 2"}, descriptions={"en": "Description 2"}, slug="collection-2",parent_id=None,has_sub_child=False) - ] - response = await get_all_collections(language="en",parent_id=None,skip=0,limit=10) - assert isinstance(response, CollectionsResponse) - assert len(response.collections) == 2 - assert response.collections[0].title == "Collection 1" - assert response.collections[1].has_child == False - mock_set_collections_cache.assert_called_once() - - -@pytest.mark.asyncio -async def test_create_new_collection(): - with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \ - patch("pecha_api.collections.collections_service.create_collection", new_callable=AsyncMock) as mock_create_collection: - mock_create_collection.return_value = AsyncMock(pecha_collection_id="pecha_new_id", titles={"en": "New Collection"}, descriptions={"en": "New Description"}, slug="new-collection",parent_id=None,has_sub_child=False) - create_collection_request = CreateCollectionRequest(slug="new-collection", titles={"en": "New Collection"}, descriptions={"en": "New Description"},parent_id=None) - response = await create_new_collection( - create_collection_request=create_collection_request, - token="valid_token", - language="en" - ) - assert isinstance(response, CollectionModel) - assert response.title == "New Collection" - assert response.slug == "new-collection" - assert response.description == "New Description" - assert response.has_child == False - - -@pytest.mark.asyncio -async def test_update_existing_collection(): - with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \ - patch("pecha_api.collections.collections_service.update_collection_titles", - new_callable=AsyncMock) as mock_update_collection_titles: - mock_update_collection_titles.return_value = AsyncMock(pecha_collection_id="pecha_id_1", titles={"en": "Updated Collection"}, descriptions={"en": "Description 1"}, slug="updated-collection",parent_id=None) - update_collection_request = UpdateCollectionRequest(titles={"en": "Updated Collection"}, descriptions={"en": "New Description"}) - response = await update_existing_collection(collection_id="1", update_collection_request=update_collection_request, token="valid_token", - language="en") - assert isinstance(response, CollectionModel) - assert response.title == "Updated Collection" - - -@pytest.mark.asyncio -async def test_delete_existing_collection(): - with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \ - patch("pecha_api.collections.collections_service.delete_collection", new_callable=AsyncMock) as mock_delete_collection: - mock_delete_collection.return_value = "id_1" - response = await delete_existing_collection(collection_id="id_1", token="valid_token") - assert response == "id_1" - - -@pytest.mark.asyncio -async def test_create_new_collection_unauthorized(): - with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=False): - create_collection_request = CreateCollectionRequest(slug="new-collection", titles={"en": "New Collection"},descriptions={"en": "New Description"},parent_id=None) - try: - await create_new_collection(create_collection_request=create_collection_request, token="invalid_token", language="en") - except HTTPException as e: - assert e.status_code == status.HTTP_403_FORBIDDEN - - -@pytest.mark.asyncio -@patch("pecha_api.collections.collections_service.verify_admin_access", return_value=False) -async def test_update_existing_collection_unauthorized(mock_verify_admin_access): - update_collection_request = UpdateCollectionRequest(titles={"en": "Updated Collection"}, descriptions={"en": "Updated Descriptions"}) - try: - await update_existing_collection(collection_id="1", update_collection_request=update_collection_request, token="invalid_token", - language=None) - except HTTPException as e: - assert e.status_code == status.HTTP_403_FORBIDDEN - - -@pytest.mark.asyncio -@patch("pecha_api.collections.collections_service.verify_admin_access", return_value=False) -async def test_delete_existing_collection_unauthorized(mock_verify_admin_access): - try: - await delete_existing_collection(collection_id="1", token="invalid_token") - except HTTPException as e: - assert e.status_code == status.HTTP_403_FORBIDDEN - - -# New test cases for uncovered scenarios - -@pytest.mark.asyncio -async def test_get_collection_with_none_id(): - """Test get_collection when collection_id is None - should return None""" - response = await get_collection(collection_id=None, language="en") - assert response is None - - -@pytest.mark.asyncio -async def test_get_collection_cache_hit(): - """Test get_collection when data is found in cache""" - cached_collection = CollectionModel( - id="cached_id", - pecha_collection_id="pecha_cached_id", - title="Cached Collection", - description="Cached Description", - language="en", - slug="cached-collection", - has_child=False - ) - - with patch("pecha_api.collections.collections_service.get_collection_detail_cache", new_callable=AsyncMock) as mock_cache_get: - mock_cache_get.return_value = cached_collection - - response = await get_collection(collection_id="cached_id", language="en") - - assert response == cached_collection - mock_cache_get.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_collection_cache_miss_collection_found(): - """Test get_collection when cache miss but collection found in database""" - mock_db_collection = AsyncMock( - pecha_collection_id="pecha_db_id", - titles={"en": "DB Collection", "es": "Colección DB"}, - descriptions={"en": "DB Description", "es": "Descripción DB"}, - slug="db-collection", - has_sub_child=True - ) - - with patch("pecha_api.collections.collections_service.get_collection_detail_cache", new_callable=AsyncMock, return_value=None) as mock_cache_get, \ - patch("pecha_api.collections.collections_service.get_collection_by_id", new_callable=AsyncMock) as mock_db_get, \ - patch("pecha_api.collections.collections_service.set_collection_detail_cache", new_callable=AsyncMock) as mock_cache_set, \ - patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["DB Collection", "DB Description"]): - - mock_db_get.return_value = mock_db_collection - - response = await get_collection(collection_id="db_id", language="en") - - assert isinstance(response, CollectionModel) - assert response.id == "db_id" - assert response.title == "DB Collection" - assert response.description == "DB Description" - assert response.slug == "db-collection" - assert response.has_child == True - assert response.language == "en" - - mock_cache_get.assert_called_once() - mock_db_get.assert_called_once_with(collection_id="db_id") - mock_cache_set.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_collection_cache_miss_collection_not_found(): - """Test get_collection when cache miss and collection not found in database""" - with patch("pecha_api.collections.collections_service.get_collection_detail_cache", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.collections.collections_service.get_collection_by_id", new_callable=AsyncMock, return_value=None): - - response = await get_collection(collection_id="nonexistent_id", language="en") - - assert response is None - - -@pytest.mark.asyncio -async def test_get_all_collections_cache_hit(): - """Test get_all_collections when data is found in cache""" - cached_response = CollectionsResponse( - parent=None, - pagination={"total": 1, "skip": 0, "limit": 10}, - collections=[ - CollectionModel( - id="cached_id", - pecha_collection_id="pecha_cached_id", - title="Cached Collection", - description="Cached Description", - language="en", - slug="cached-collection", - has_child=False - ) - ] - ) - - with patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock) as mock_cache_get: - mock_cache_get.return_value = cached_response - - response = await get_all_collections(language="en", parent_id=None, skip=0, limit=10) - - assert response == cached_response - mock_cache_get.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_all_collections_with_default_language(): - """Test get_all_collections when language is None - should use default language""" - with patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.collections.collections_service.get_child_count", new_callable=AsyncMock, return_value=1), \ - patch("pecha_api.collections.collections_service.get_collection", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.collections.collections_service.get_collections_by_parent", new_callable=AsyncMock) as mock_get_collections, \ - patch("pecha_api.collections.collections_service.set_collections_cache", new_callable=AsyncMock), \ - patch("pecha_api.collections.collections_service.get", return_value="en") as mock_get_config: - - mock_get_collections.return_value = [ - AsyncMock(id="test_id", pecha_collection_id="pecha_test_id", titles={"en": "Test"}, descriptions={"en": "Test Desc"}, slug="test", has_sub_child=False) - ] - - response = await get_all_collections(language=None, parent_id=None, skip=0, limit=10) - - mock_get_config.assert_called_with("DEFAULT_LANGUAGE") - assert isinstance(response, CollectionsResponse) - - -@pytest.mark.asyncio -async def test_create_new_collection_with_default_language(): - """Test create_new_collection when language is None - should use default language""" - with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \ - patch("pecha_api.collections.collections_service.create_collection", new_callable=AsyncMock) as mock_create, \ - patch("pecha_api.collections.collections_service.get", return_value="en") as mock_get_config, \ - patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["New Collection", "New Description"]): - - mock_create.return_value = AsyncMock( - id="new_id", - pecha_collection_id="pecha_new_id", - titles={"en": "New Collection"}, - descriptions={"en": "New Description"}, - slug="new-collection", - has_sub_child=False - ) - - create_request = CreateCollectionRequest( - slug="new-collection", - titles={"en": "New Collection"}, - descriptions={"en": "New Description"}, - parent_id=None - ) - - response = await create_new_collection( - create_collection_request=create_request, - token="valid_token", - language=None - ) - - mock_get_config.assert_called_with("DEFAULT_LANGUAGE") - assert isinstance(response, CollectionModel) - assert response.language == "en" - - -@pytest.mark.asyncio -async def test_update_existing_collection_with_default_language(): - """Test update_existing_collection when language is None - should use default language""" - with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \ - patch("pecha_api.collections.collections_service.update_collection_titles", new_callable=AsyncMock) as mock_update, \ - patch("pecha_api.collections.collections_service.get", return_value="en") as mock_get_config, \ - patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["Updated Collection", "Updated Description"]): - - mock_update.return_value = AsyncMock( - pecha_collection_id="pecha_test_id", - titles={"en": "Updated Collection"}, - descriptions={"en": "Updated Description"}, - slug="updated-collection", - has_sub_child=True - ) - - update_request = UpdateCollectionRequest( - titles={"en": "Updated Collection"}, - descriptions={"en": "Updated Description"} - ) - - response = await update_existing_collection( - collection_id="test_id", - update_collection_request=update_request, - token="valid_token", - language=None - ) - - mock_get_config.assert_called_with("DEFAULT_LANGUAGE") - assert isinstance(response, CollectionModel) - assert response.language == "en" - - -@pytest.mark.asyncio -async def test_get_all_collections_cache_miss_with_parent(): - """Test get_all_collections cache miss scenario with parent_id provided""" - parent_collection = CollectionModel( - id="parent_id", - pecha_collection_id="pecha_parent_id", - title="Parent Collection", - description="Parent Description", - language="en", - slug="parent-collection", - has_child=True - ) - - with patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.collections.collections_service.get_child_count", new_callable=AsyncMock, return_value=2), \ - patch("pecha_api.collections.collections_service.get_collection", new_callable=AsyncMock, return_value=parent_collection), \ - patch("pecha_api.collections.collections_service.get_collections_by_parent", new_callable=AsyncMock) as mock_get_collections, \ - patch("pecha_api.collections.collections_service.set_collections_cache", new_callable=AsyncMock) as mock_cache_set, \ - patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["Child 1", "Child 1 Desc", "Child 2", "Child 2 Desc"]): - - mock_get_collections.return_value = [ - AsyncMock(id="child1", pecha_collection_id="pecha_child1", titles={"en": "Child 1"}, descriptions={"en": "Child 1 Desc"}, slug="child-1", has_sub_child=False), - AsyncMock(id="child2", pecha_collection_id="pecha_child2", titles={"en": "Child 2"}, descriptions={"en": "Child 2 Desc"}, slug="child-2", has_sub_child=False) - ] - - response = await get_all_collections(language="en", parent_id="parent_id", skip=0, limit=10) - - assert isinstance(response, CollectionsResponse) - assert response.parent == parent_collection - assert len(response.collections) == 2 - assert response.pagination.total == 2 - mock_cache_set.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_collection_by_pecha_collection_id_service_found(): - """Test get_collection_by_pecha_collection_id_service when collection is found""" - with patch("pecha_api.collections.collections_service.get_collection_id_by_pecha_collection_id", - new_callable=AsyncMock, return_value="collection_id_123"): - - result = await get_collection_by_pecha_collection_id_service(pecha_collection_id="pecha_123") - - assert result == "collection_id_123" - - -@pytest.mark.asyncio -async def test_get_collection_by_pecha_collection_id_service_not_found(): - """Test get_collection_by_pecha_collection_id_service when collection is not found""" - with patch("pecha_api.collections.collections_service.get_collection_id_by_pecha_collection_id", - new_callable=AsyncMock, return_value=None): - - result = await get_collection_by_pecha_collection_id_service(pecha_collection_id="nonexistent_pecha_id") - - assert result is None diff --git a/tests/collection/test_collection_views.py b/tests/collection/test_collection_views.py deleted file mode 100644 index 72fb64362..000000000 --- a/tests/collection/test_collection_views.py +++ /dev/null @@ -1,277 +0,0 @@ -import asyncio -import pytest -from unittest.mock import patch, AsyncMock -from fastapi.testclient import TestClient -from fastapi import HTTPException -from starlette import status -from httpx import AsyncClient, ASGITransport - -from pecha_api.app import api -from pecha_api.collections.collections_response_models import ( - CollectionModel, - CollectionsResponse, - CreateCollectionRequest, - UpdateCollectionRequest, - Pagination -) - -client = TestClient(api) - -# Test constants -VALID_TOKEN = "valid_admin_token" -INVALID_TOKEN = "invalid_token" -COLLECTION_ID = "60d21b4667d0d8992e610c85" - -# Mock data -MOCK_COLLECTION = CollectionModel( - id=COLLECTION_ID, - pecha_collection_id="pecha_60d21b4667d0d8992e610c85", - title="Test Collection", - description="Test Description", - language="en", - slug="test-collection", - has_child=False -) - -MOCK_COLLECTIONS_RESPONSE = CollectionsResponse( - parent=None, - pagination=Pagination(total=2, skip=0, limit=10), - collections=[ - MOCK_COLLECTION, - CollectionModel( - id="60d21b4667d0d8992e610c86", - pecha_collection_id="pecha_60d21b4667d0d8992e610c86", - title="Another Collection", - description="Another Description", - language="en", - slug="another-collection", - has_child=True - ) - ] -) - -MOCK_CREATE_REQUEST = CreateCollectionRequest( - slug="new-collection", - titles={"en": "New Collection"}, - descriptions={"en": "New Description"}, - parent_id=None -) - -MOCK_UPDATE_REQUEST = UpdateCollectionRequest( - titles={"en": "Updated Collection"}, - descriptions={"en": "Updated Description"} -) - - -# Tests for GET /collections endpoint - -@pytest.mark.asyncio -async def test_read_collections_success(): - # Test successful retrieval of collections - with patch("pecha_api.collections.collections_views.get_all_collections", - new_callable=AsyncMock, return_value=MOCK_COLLECTIONS_RESPONSE): - - response = client.get("/collections") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["pagination"]["total"] == 2 - assert len(data["collections"]) == 2 - assert data["collections"][0]["title"] == "Test Collection" - assert data["collections"][1]["has_child"] == True - - -@pytest.mark.asyncio -async def test_read_collections_with_filters(): - # Test reading collections with query parameters - with patch("pecha_api.collections.collections_views.get_all_collections", - new_callable=AsyncMock, return_value=MOCK_COLLECTIONS_RESPONSE): - - response = client.get("/collections", params={ - "parent_id": COLLECTION_ID, - "language": "bo", - "skip": 10, - "limit": 5 - }) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.asyncio -async def test_read_collections_service_error(): - # Test handling of service errors - with patch("pecha_api.collections.collections_views.get_all_collections", - new_callable=AsyncMock, side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - )): - - response = client.get("/collections") - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - - -# Tests for POST /collections endpoint - -@pytest.mark.asyncio -async def test_create_collection_success(): - # Test successful collection creation - with patch("pecha_api.collections.collections_views.create_new_collection", - new_callable=AsyncMock, return_value=MOCK_COLLECTION): - - response = client.post("/collections", - json=MOCK_CREATE_REQUEST.model_dump(), - headers={"Authorization": f"Bearer {VALID_TOKEN}"}, - params={"language": "en"}) - - assert response.status_code == status.HTTP_201_CREATED - data = response.json() - assert data["title"] == "Test Collection" - assert data["slug"] == "test-collection" - assert data["has_child"] == False - - -@pytest.mark.asyncio -async def test_create_collection_without_auth(): - # Test collection creation without authentication - response = client.post("/collections", - json=MOCK_CREATE_REQUEST.model_dump()) - - assert response.status_code == status.HTTP_403_FORBIDDEN - - -@pytest.mark.asyncio -async def test_create_collection_invalid_data(): - # Test collection creation with invalid data - invalid_data = { - "slug": "", # Empty slug should fail validation - "titles": {}, # Empty titles - "descriptions": {}, - "parent_id": None - } - - response = client.post("/collections", - json=invalid_data, - headers={"Authorization": f"Bearer {VALID_TOKEN}"}) - - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -# Tests for PUT /collections/{collection_id} endpoint - -@pytest.mark.asyncio -async def test_update_collection_success(): - # Test successful collection update - updated_collection = CollectionModel( - id=COLLECTION_ID, - pecha_collection_id="pecha_60d21b4667d0d8992e610c85", - title="Updated Collection", - description="Updated Description", - language="en", - slug="test-collection", - has_child=False - ) - - with patch("pecha_api.collections.collections_views.update_existing_collection", - new_callable=AsyncMock, return_value=updated_collection): - - response = client.put(f"/collections/{COLLECTION_ID}", - json=MOCK_UPDATE_REQUEST.model_dump(), - headers={"Authorization": f"Bearer {VALID_TOKEN}"}, - params={"language": "en"}) - - assert response.status_code == status.HTTP_202_ACCEPTED - data = response.json() - assert data["title"] == "Updated Collection" - assert data["description"] == "Updated Description" - - -@pytest.mark.asyncio -async def test_update_collection_without_auth(): - # Test collection update without authentication - response = client.put(f"/collections/{COLLECTION_ID}", - json=MOCK_UPDATE_REQUEST.model_dump()) - - assert response.status_code == status.HTTP_403_FORBIDDEN - -@pytest.mark.asyncio -async def test_update_collection_invalid_data(): - # Test collection update with invalid data - invalid_data = { - "titles": {}, # Empty titles should fail validation - "descriptions": {} - } - - response = client.put(f"/collections/{COLLECTION_ID}", - json=invalid_data, - headers={"Authorization": f"Bearer {VALID_TOKEN}"}) - - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -# Tests for DELETE /collections/{collection_id} endpoint - -@pytest.mark.asyncio -async def test_delete_collection_success(): - # Test successful collection deletion - with patch("pecha_api.collections.collections_views.delete_existing_collection", - new_callable=AsyncMock, return_value=COLLECTION_ID): - - response = client.delete(f"/collections/{COLLECTION_ID}", - headers={"Authorization": f"Bearer {VALID_TOKEN}"}) - - assert response.status_code == status.HTTP_204_NO_CONTENT - - -@pytest.mark.asyncio -async def test_delete_collection_without_auth(): - # Test collection deletion without authentication - response = client.delete(f"/collections/{COLLECTION_ID}") - - assert response.status_code == status.HTTP_403_FORBIDDEN - - -@pytest.mark.asyncio -async def test_delete_collection_invalid_token(): - # Test collection deletion with invalid token - patch where views imports it - with patch("pecha_api.collections.collections_views.delete_existing_collection", - new_callable=AsyncMock, side_effect=HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin access required" - )): - - response = client.delete(f"/collections/{COLLECTION_ID}", - headers={"Authorization": f"Bearer {INVALID_TOKEN}"}) - - assert response.status_code == status.HTTP_403_FORBIDDEN - - -@pytest.mark.asyncio -async def test_delete_collection_not_found(): - # Test deleting non-existent collection - patch where views imports it - with patch("pecha_api.collections.collections_views.delete_existing_collection", - new_callable=AsyncMock, side_effect=HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Collection not found" - )): - - response = client.delete(f"/collections/{COLLECTION_ID}", - headers={"Authorization": f"Bearer {VALID_TOKEN}"}) - - assert response.status_code == status.HTTP_404_NOT_FOUND - - -@pytest.mark.asyncio -async def test_delete_collection_with_children(): - # Test deleting collection that has children - patch where views imports it - with patch("pecha_api.collections.collections_views.delete_existing_collection", - new_callable=AsyncMock, side_effect=HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot delete collection with children" - )): - - response = client.delete(f"/collections/{COLLECTION_ID}", - headers={"Authorization": f"Bearer {VALID_TOKEN}"}) - - assert response.status_code == status.HTTP_400_BAD_REQUEST - diff --git a/tests/collection/test_collections_cache_service.py b/tests/collection/test_collections_cache_service.py deleted file mode 100644 index af3d4768b..000000000 --- a/tests/collection/test_collections_cache_service.py +++ /dev/null @@ -1,367 +0,0 @@ -import pytest -from unittest.mock import patch, AsyncMock, MagicMock - -from pecha_api.collections.collections_cache_service import ( - get_collections_cache, - set_collections_cache, - get_collection_detail_cache, - set_collection_detail_cache, - delete_collection_cache -) -from pecha_api.collections.collections_response_models import ( - CollectionsResponse, - CollectionModel, - Pagination -) -from pecha_api.cache.cache_enums import CacheType - - -@pytest.mark.asyncio -async def test_get_collections_cache_empty_cache(): - #Test get_collections_cache when cache is empty/None. - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None): - - response = await get_collections_cache( - parent_id="parent_id", - language="en", - skip=0, - limit=10, - cache_type=CacheType.COLLECTIONS - ) - - assert response is None - - -@pytest.mark.asyncio -async def test_get_collections_cache_success(): - #Test get_collections_cache when cache returns valid CollectionsResponse object. - mock_collection = CollectionModel( - id="collection_id", - title="Test Collection", - description="Test Description", - language="en", - slug="test-collection", - has_child=False - ) - - mock_pagination = Pagination(total=1, skip=0, limit=10) - - mock_collections_response = CollectionsResponse( - parent=None, - pagination=mock_pagination, - collections=[mock_collection] - ) - - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_collections_response): - - response = await get_collections_cache( - parent_id="parent_id", - language="en", - skip=0, - limit=10, - cache_type=CacheType.COLLECTIONS - ) - - assert response is not None - assert isinstance(response, CollectionsResponse) - assert len(response.collections) == 1 - assert response.collections[0].id == "collection_id" - assert response.pagination.total == 1 - - -@pytest.mark.asyncio -async def test_get_collections_cache_with_dict_response(): - #Test get_collections_cache when cache returns dict and needs conversion. - mock_cache_dict = { - "parent": None, - "pagination": {"total": 1, "skip": 0, "limit": 10}, - "collections": [{ - "id": "collection_id", - "title": "Test Collection", - "description": "Test Description", - "language": "en", - "slug": "test-collection", - "has_child": False - }] - } - - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict): - - response = await get_collections_cache( - parent_id="parent_id", - language="en", - skip=0, - limit=10, - cache_type=CacheType.COLLECTIONS - ) - - assert response is not None - assert isinstance(response, CollectionsResponse) - assert len(response.collections) == 1 - assert response.collections[0].id == "collection_id" - - -@pytest.mark.asyncio -async def test_set_collections_cache_success(): - #Test set_collections_cache successfully sets cache. - mock_collection = CollectionModel( - id="collection_id", - title="Test Collection", - description="Test Description", - language="en", - slug="test-collection", - has_child=False - ) - - mock_pagination = Pagination(total=1, skip=0, limit=10) - - mock_collections_response = CollectionsResponse( - parent=None, - pagination=mock_pagination, - collections=[mock_collection] - ) - - with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \ - patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config: - - await set_collections_cache( - parent_id="parent_id", - language="en", - skip=0, - limit=10, - data=mock_collections_response, - cache_type=CacheType.COLLECTIONS - ) - - mock_set_cache.assert_called_once() - mock_config.assert_called_once_with("CACHE_COLLECTION_TIMEOUT") - - # Verify the call arguments - call_args = mock_set_cache.call_args - assert "hash_key" in call_args.kwargs - assert call_args.kwargs["value"] == mock_collections_response - assert call_args.kwargs["cache_time_out"] == 1800 - - -@pytest.mark.asyncio -async def test_get_collection_detail_cache_empty_cache(): - #Test get_collection_detail_cache when cache is empty/None. - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None): - - response = await get_collection_detail_cache( - collection_id="collection_id", - language="en", - cache_type=CacheType.COLLECTION_DETAIL - ) - - assert response is None - - -@pytest.mark.asyncio -async def test_get_collection_detail_cache_success(): - #Test get_collection_detail_cache when cache returns valid CollectionModel object. - mock_collection = CollectionModel( - id="collection_id", - title="Test Collection", - description="Test Description", - language="en", - slug="test-collection", - has_child=True - ) - - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_collection): - - response = await get_collection_detail_cache( - collection_id="collection_id", - language="en", - cache_type=CacheType.COLLECTION_DETAIL - ) - - assert response is not None - assert isinstance(response, CollectionModel) - assert response.id == "collection_id" - assert response.title == "Test Collection" - assert response.has_child is True - - -@pytest.mark.asyncio -async def test_get_collection_detail_cache_with_dict_response(): - #Test get_collection_detail_cache when cache returns dict and needs conversion. - mock_cache_dict = { - "id": "collection_id", - "title": "Test Collection", - "description": "Test Description", - "language": "en", - "slug": "test-collection", - "has_child": True - } - - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict): - - response = await get_collection_detail_cache( - collection_id="collection_id", - language="en", - cache_type=CacheType.COLLECTION_DETAIL - ) - - assert response is not None - assert isinstance(response, CollectionModel) - assert response.id == "collection_id" - assert response.has_child is True - - -@pytest.mark.asyncio -async def test_set_collection_detail_cache_success(): - #Test set_collection_detail_cache successfully sets cache. - mock_collection = CollectionModel( - id="collection_id", - title="Test Collection", - description="Test Description", - language="en", - slug="test-collection", - has_child=False - ) - - with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \ - patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config: - - await set_collection_detail_cache( - collection_id="collection_id", - language="en", - data=mock_collection, - cache_type=CacheType.COLLECTION_DETAIL - ) - - mock_set_cache.assert_called_once() - mock_config.assert_called_once_with("CACHE_COLLECTION_TIMEOUT") - - # Verify the call arguments - call_args = mock_set_cache.call_args - assert "hash_key" in call_args.kwargs - assert call_args.kwargs["value"] == mock_collection - assert call_args.kwargs["cache_time_out"] == 1800 - - -@pytest.mark.asyncio -async def test_delete_collection_cache_success(): - #Test delete_collection_cache successfully clears cache. - with patch("pecha_api.collections.collections_cache_service.clear_cache", new_callable=AsyncMock) as mock_clear_cache: - - await delete_collection_cache( - collection_id="collection_id", - cache_type=CacheType.COLLECTION_DETAIL - ) - - mock_clear_cache.assert_called_once() - - # Verify the call arguments - call_args = mock_clear_cache.call_args - assert "hash_key" in call_args.kwargs - - -@pytest.mark.asyncio -async def test_get_collections_cache_hash_key_generation(): - #Test that correct hash key is generated for get_collections_cache. - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None) as mock_get_cache, \ - patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash: - - await get_collections_cache( - parent_id="parent_id", - language="en", - skip=0, - limit=10, - cache_type=CacheType.COLLECTIONS - ) - - mock_generate_hash.assert_called_once_with(payload=["parent_id", "en", 0, 10, CacheType.COLLECTIONS]) - mock_get_cache.assert_called_once_with(hash_key="test_hash_key") - - -@pytest.mark.asyncio -async def test_get_collection_detail_cache_hash_key_generation(): - #Test that correct hash key is generated for get_collection_detail_cache. - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None) as mock_get_cache, \ - patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash: - - await get_collection_detail_cache( - collection_id="collection_id", - language="en", - cache_type=CacheType.COLLECTION_DETAIL - ) - - mock_generate_hash.assert_called_once_with(payload=["collection_id", "en", CacheType.COLLECTION_DETAIL]) - mock_get_cache.assert_called_once_with(hash_key="test_hash_key") - - -@pytest.mark.asyncio -async def test_delete_collection_cache_hash_key_generation(): - #Test that correct hash key is generated for delete_collection_cache. - with patch("pecha_api.collections.collections_cache_service.clear_cache", new_callable=AsyncMock) as mock_clear_cache, \ - patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash: - - await delete_collection_cache( - collection_id="collection_id", - cache_type=CacheType.COLLECTION_DETAIL - ) - - mock_generate_hash.assert_called_once_with(payload=["collection_id", CacheType.COLLECTION_DETAIL]) - mock_clear_cache.assert_called_once_with(hash_key="test_hash_key") - - -@pytest.mark.asyncio -async def test_collections_cache_with_none_parameters(): - #Test cache functions work correctly with None parameters. - with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None) as mock_get_cache, \ - patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash: - - # Test with None values - await get_collections_cache( - parent_id=None, - language=None, - skip=None, - limit=None, - cache_type=None - ) - - mock_generate_hash.assert_called_once_with(payload=[None, None, None, None, None]) - mock_get_cache.assert_called_once_with(hash_key="test_hash_key") - - -@pytest.mark.asyncio -async def test_set_collections_cache_with_none_data(): - #Test set_collections_cache handles None data correctly. - with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \ - patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config: - - await set_collections_cache( - parent_id="parent_id", - language="en", - skip=0, - limit=10, - data=None, - cache_type=CacheType.COLLECTIONS - ) - - mock_set_cache.assert_called_once() - call_args = mock_set_cache.call_args - assert call_args.kwargs["value"] is None - assert call_args.kwargs["cache_time_out"] == 1800 - - -@pytest.mark.asyncio -async def test_set_collection_detail_cache_with_none_data(): - #Test set_collection_detail_cache handles None data correctly. - with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \ - patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config: - - await set_collection_detail_cache( - collection_id="collection_id", - language="en", - data=None, - cache_type=CacheType.COLLECTION_DETAIL - ) - - mock_set_cache.assert_called_once() - call_args = mock_set_cache.call_args - assert call_args.kwargs["value"] is None - assert call_args.kwargs["cache_time_out"] == 1800 \ No newline at end of file From 40423ea67ec1526a70d990b723b6bf20b2470ffb Mon Sep 17 00:00:00 2001 From: TenzDelek <122612557+TenzDelek@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:22:50 +0530 Subject: [PATCH 03/29] Merge pull request #655 from OpenPecha/clean-text Text Migration --- pecha_api/app.py | 2 - pecha_api/texts/texts_cache_service.py | 77 +- pecha_api/texts/texts_repository.py | 40 - pecha_api/texts/texts_service.py | 825 +---- pecha_api/texts/texts_views.py | 149 - tests/texts/test_text_cache_service.py | 487 --- tests/texts/test_texts_service.py | 4385 +++--------------------- tests/texts/test_texts_views.py | 1348 -------- 8 files changed, 448 insertions(+), 6865 deletions(-) delete mode 100644 pecha_api/texts/texts_views.py delete mode 100644 tests/texts/test_texts_views.py diff --git a/pecha_api/app.py b/pecha_api/app.py index e93d8a965..50a601039 100644 --- a/pecha_api/app.py +++ b/pecha_api/app.py @@ -7,7 +7,6 @@ from pecha_api.db.mongo_database import lifespan from pecha_api.auth import auth_views from pecha_api.terms import terms_views -from pecha_api.texts import texts_views from pecha_api.topics import topics_views from pecha_api.users import users_views from pecha_api.texts.groups import groups_views @@ -48,7 +47,6 @@ ) api.include_router(auth_views.auth_router) api.include_router(terms_views.terms_router) -api.include_router(texts_views.text_router) api.include_router(groups_views.group_router) api.include_router(topics_views.topics_router) api.include_router(users_views.user_router) diff --git a/pecha_api/texts/texts_cache_service.py b/pecha_api/texts/texts_cache_service.py index ea4eaca67..d9beb5bb1 100644 --- a/pecha_api/texts/texts_cache_service.py +++ b/pecha_api/texts/texts_cache_service.py @@ -9,86 +9,13 @@ invalidate_text_related_cache, invalidate_multiple_cache_keys, ) -from .texts_response_models import ( - DetailTableOfContentResponse, - TextsCategoryResponse, - TableOfContentResponse, - TextVersionResponse, - TextDTO, - TableOfContent -) +from .texts_response_models import TextDTO + from pecha_api.cache.cache_enums import CacheType -from typing import Optional import logging from pecha_api import config -async def set_text_details_cache(text_id: str = None, content_id: str = None, version_id: str = None, skip: int = None, limit: int = None, data: DetailTableOfContentResponse = None, cache_type: CacheType = None): - #Set text details cache asynchronously. - payload = [text_id, content_id, version_id, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def get_text_details_cache(text_id: str = None, content_id: str = None, version_id: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> DetailTableOfContentResponse: - #Get text details cache asynchronously. - payload = [text_id, content_id, version_id, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: DetailTableOfContentResponse = await get_cache_data(hash_key =hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = DetailTableOfContentResponse(**cache_data) - return cache_data - - -async def get_text_by_text_id_or_collection_cache(text_id: str = None, collection_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TextsCategoryResponse | TextDTO: - """Get text by text id or collection cache asynchronously.""" - payload = [text_id, collection_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: TextsCategoryResponse | TextDTO = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = TextsCategoryResponse(**cache_data) - return cache_data - - -async def set_text_by_text_id_or_collection_cache(text_id: str = None, collection_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None, data: TextsCategoryResponse = None): - """Set text by text_id or collection cache asynchronously.""" - payload = [text_id, collection_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def get_table_of_contents_by_text_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TableOfContentResponse: - """Get table of contents by text id cache asynchronously.""" - payload = [text_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: TableOfContentResponse = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = TableOfContentResponse(**cache_data) - return cache_data - -async def set_table_of_contents_by_text_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, data: TableOfContentResponse = None, cache_type: CacheType = None): - """Set table of contents by text_id cache asynchronously.""" - payload = [text_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - -async def get_text_versions_by_group_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TextVersionResponse: - #Get text versions by group_id cache asynchronously. - payload = [text_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_data: TextVersionResponse = await get_cache_data(hash_key = hashed_key) - if cache_data and isinstance(cache_data, dict): - cache_data = TextVersionResponse(**cache_data) - return cache_data - -async def set_text_versions_by_group_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, data: TextVersionResponse = None, cache_type: CacheType = None): - #Set text versions by group_id cache asynchronously. - payload = [text_id, language, skip, limit, cache_type] - hashed_key: str = Utils.generate_hash_key(payload = payload) - cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT") - await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out) - async def set_text_details_by_id_cache(text_id: str = None, cache_type: CacheType = None, data: TextDTO = None): """Set text details by id cache asynchronously.""" payload = [text_id, cache_type] diff --git a/pecha_api/texts/texts_repository.py b/pecha_api/texts/texts_repository.py index d5417d098..75d204ea8 100644 --- a/pecha_api/texts/texts_repository.py +++ b/pecha_api/texts/texts_repository.py @@ -30,9 +30,6 @@ async def get_text_by_pecha_text_id(pecha_text_id: str) -> Text | None: logging.debug(e) return None -async def get_sections_count_of_table_of_content(content_id: str) -> int: - return await TableOfContent.get_sections_count(content_id=content_id) - async def get_texts_by_id(text_id: str) -> Text | None: try: text = await Text.get_text(text_id=text_id) @@ -81,45 +78,12 @@ async def check_all_text_exists(text_ids: List[UUID]) -> bool: logging.debug(e) return False -async def get_texts_by_collection(collection_id: str, skip: int, limit: int) -> List[Text]: - return await Text.get_texts_by_collection_id(collection_id=collection_id, skip=skip, limit=limit) - async def get_all_texts_by_collection(collection_id: str) -> List[Text]: return await Text.get_all_texts_by_collection_id(collection_id=collection_id) async def get_all_recitation_texts_by_collection(collection_id: str, language: str) -> List[Text]: return await Text.get_all_recitation_texts_by_collection_id(collection_id=collection_id, language=language) -async def get_texts_by_group_id(group_id: str, skip: int, limit: int) -> List[TextDTO]: - texts = await Text.get_texts_by_group_id(group_id=group_id, skip=skip, limit=limit) - return [ - TextDTO( - id=str(text.id), - pecha_text_id=str(text.pecha_text_id), - title=text.title, - language=text.language, - group_id=text.group_id, - type=text.type, - is_published=text.is_published, - created_date=text.created_date, - updated_date=text.updated_date, - published_date=text.published_date, - published_by=text.published_by, - categories=text.categories, - views=text.views, - source_link=text.source_link, - ranking=text.ranking, - license=text.license - ) - for text in texts - ] - -async def get_texts_by_titles(titles: List[str]) -> List[Text]: - if not titles: - return [] - return await Text.find({"title": {"$in": titles}}).to_list() - - async def get_all_texts_by_group_id(group_id: str) -> List[TextDTO]: texts = await Text.get_all_texts_by_group_id(group_id=group_id) return [ @@ -173,10 +137,6 @@ async def create_table_of_content_detail(table_of_content_request: TableOfConten async def get_contents_by_id(text_id: str) -> List[TableOfContent]: return await TableOfContent.get_table_of_contents_by_text_id(text_id=text_id) -async def get_table_of_content_by_content_id(content_id: str, skip: int = None, limit: int = None) -> Optional[TableOfContent]: - return await TableOfContent.get_table_of_content_by_content_id(content_id=content_id, skip=skip, limit=limit) - - async def delete_table_of_content_by_text_id(text_id: str): return await TableOfContent.delete_table_of_content_by_text_id(text_id=text_id) diff --git a/pecha_api/texts/texts_service.py b/pecha_api/texts/texts_service.py index 06159ed99..3f6e668e1 100644 --- a/pecha_api/texts/texts_service.py +++ b/pecha_api/texts/texts_service.py @@ -1,68 +1,33 @@ from fastapi import HTTPException from starlette import status +from pecha_api.config import get from pecha_api.error_contants import ErrorConstants -from pecha_api.http_message_utils import handle_http_status_error, handle_request_error from .texts_repository import ( - get_all_texts_by_collection, - get_texts_by_group_id, - create_text, create_table_of_content_detail, get_contents_by_id, - get_table_of_content_by_content_id, delete_table_of_content_by_text_id, update_text_details_by_id, delete_text_by_id, - get_all_texts_by_collection, get_all_recitation_texts_by_collection, get_texts_by_pecha_text_ids, - get_texts_by_titles, - get_all_texts_by_group_id - ) from .texts_response_models import ( TableOfContent, TableOfContentType, - DetailTableOfContentResponse, - TableOfContentResponse, TextDTO, TextSegment, - TextVersionResponse, - TextVersion, - TextsCategoryResponse, - CreateTextRequest, - TextDetailsRequest, - UpdateTextRequest, - TextDetailsRequest, Section, - DetailTableOfContentResponse, + UpdateTextRequest, TextsByPechaTextIdsRequest, - TitleSearchResult, - LanguageResponse, - AvailableLanguage, - VersionDetail, - VersionsResponse ) -from pecha_api.recitations.recitations_response_models import( - RecitationDTO, - RecitationsResponse +from pecha_api.recitations.recitations_response_models import ( + RecitationDTO, + RecitationsResponse ) -from pecha_api.constants import Constants -from pecha_api.plans.response_message import ( - EXTERNAL_PECHA_API_URL_NOT_CONFIGURED, - TITLE_OR_AUTHOR_QUERY_REQUIRED, -) - -from .groups.groups_service import ( - validate_group_exists -) -from .segments.segments_models import Segment from pecha_api.texts.texts_cache_service import ( - get_text_by_text_id_or_collection_cache, - set_text_by_text_id_or_collection_cache, - set_text_versions_by_group_id_cache, update_text_details_cache, invalidate_text_cache_on_update ) @@ -71,135 +36,13 @@ from .texts_utils import TextUtils from pecha_api.users.users_service import validate_user_exists -from pecha_api.collections.collections_service import get_collection -from pecha_api.users.users_service import ( - validate_user_exists -) from .segments.segments_utils import SegmentUtils -from typing import List, Dict, Optional, Tuple, Set -from pecha_api.config import get +from typing import List, Dict, Optional from pecha_api.utils import Utils -from .texts_enums import PaginationDirection, TextType, TextTypes +from .texts_enums import TextType import logging -import httpx - -EXTERNAL_TITLE_SEARCH_API_URL = get("EXTERNAL_TITLE_SEARCH_API_URL") -ACCEPT_JSON_HEADER = {"Accept": "application/json"} - - -def extract_title_for_language(title_payload: object, language: Optional[str]) -> Optional[str]: - if isinstance(title_payload, dict): - if language and isinstance(title_payload.get(language), str): - return title_payload.get(language) - for value in title_payload.values(): - if isinstance(value, str) and value.strip(): - return value - return None - if isinstance(title_payload, str): - return title_payload.strip() or None - return None - - -async def get_text_by_text_id_or_collection( - text_id: str, - collection_id: Optional[str] = None, - language: Optional[str] = None, - skip: int = 0, - limit: int = 10 -) -> TextsCategoryResponse | TextDTO: - if language is None: - language = get("DEFAULT_LANGUAGE") - - cached_data: TextsCategoryResponse | TextDTO = await get_text_by_text_id_or_collection_cache( - text_id = text_id, - collection_id = collection_id, - language = language, - skip = skip, - limit = limit, - cache_type = CacheType.TEXTS_BY_ID_OR_COLLECTION - ) - - if cached_data is not None: - return cached_data - - if collection_id is not None: - collection = await get_collection(collection_id=collection_id, language=language) - texts, total_unique_group_ids = await _get_texts_by_collection_id(collection_id=collection_id, language=language, skip=skip, limit=limit) - - response = TextsCategoryResponse( - collection=collection, - texts=texts, - total=total_unique_group_ids, - skip=skip, - limit=limit - ) - else: - response = await TextUtils.get_text_detail_by_id(text_id=text_id) - - await set_text_by_text_id_or_collection_cache( - text_id = text_id, - collection_id = collection_id, - language = language, - skip = skip, - limit = limit, - cache_type = CacheType.TEXTS_BY_ID_OR_COLLECTION, - data = response - ) - return response - - -async def get_titles_and_ids_by_query( - title: Optional[str], - author: Optional[str], - limit: int, - offset: int -) -> List[TitleSearchResult]: - if not title and not author: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=TITLE_OR_AUTHOR_QUERY_REQUIRED - ) - if not EXTERNAL_TITLE_SEARCH_API_URL: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=EXTERNAL_PECHA_API_URL_NOT_CONFIGURED - ) - - params: Dict[str, object] = {"limit": limit, "offset": offset} - if title: - params["title"] = title - if author: - params["author"] = author - - endpoint = f"{EXTERNAL_TITLE_SEARCH_API_URL}/v2/texts" - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: - response = await client.get(endpoint, headers=ACCEPT_JSON_HEADER, params=params) - response.raise_for_status() - data = response.json() - except httpx.HTTPStatusError as e: - handle_http_status_error(e) - except httpx.RequestError as e: - handle_request_error(e) - - titles = [] - for item in data or []: - if not isinstance(item, dict): - continue - language = item.get("language") - title_value = extract_title_for_language(item.get("title"), language) - if title_value: - titles.append(title_value) - - unique_titles = list(dict.fromkeys(titles)) - if not unique_titles: - return [] - - texts = await get_texts_by_titles(titles=unique_titles) - return [TitleSearchResult(id=str(text.id), title=text.title) for text in texts] - async def get_table_of_contents_by_text_id(text_id: str, language: str = None, skip: int = 0, limit: int = 10) -> TableOfContentResponse: @@ -256,138 +99,8 @@ def _get_paginated_sections(sections: List[Section], skip: int, limit: int) -> L return filtered_sections[skip_index:limit_index] -async def remove_table_of_content_by_text_id(text_id: str): - is_valid_text = await TextUtils.validate_text_exists(text_id=text_id) - if not is_valid_text: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE) - return await delete_table_of_content_by_text_id(text_id=text_id) - - -# NEW TEXT DETAILS SERVICE -async def get_text_details_by_text_id( - text_id: str, - text_details_request: TextDetailsRequest -) -> DetailTableOfContentResponse: - - await _validate_text_detail_request( - text_id=text_id, - text_details_request=text_details_request - ) - selected_text = await TextUtils.get_text_detail_by_id(text_id=text_id) - - table_of_content: TableOfContent = await _receive_table_of_content( - text_id=text_id, - text_details_request=text_details_request - ) - segments_with_position: List[Tuple[str, int]] = _get_segments_with_position_( - table_of_content=table_of_content, - ) - total_segments = len(segments_with_position) - trimmed_segment_dict = _get_trimmed_segment_dict_( - segments_with_position=segments_with_position, - segment_id=text_details_request.segment_id, - direction=text_details_request.direction, - size=text_details_request.size - ) - current_segment_position = trimmed_segment_dict.get(text_details_request.segment_id) - paginated_table_of_content: TableOfContent = _generate_paginated_table_of_content_by_segments_( - table_of_content = table_of_content, - segment_dict = trimmed_segment_dict - ) - - detail_table_of_content: DetailTableOfContentResponse = await _mapping_table_of_content( - text=selected_text, - table_of_content=paginated_table_of_content, - version_id=text_details_request.version_id, - size=text_details_request.size, - total_segments=total_segments, - current_segment_position=current_segment_position, - pagination_direction=text_details_request.direction - ) - - return detail_table_of_content - - -async def get_text_versions_by_group_id(text_id: str, language: str, skip: int, limit: int) -> TextVersionResponse: - ''' - This function will first retrive the group_id from the text_id details - It will retrieve all the texts with same group_id - Then root text will be determined by the language provied - Left texts will be considered as versions - ''' - if language is None: - language = get("DEFAULT_LANGUAGE") - - # cached_data: TextVersionResponse = await get_text_versions_by_group_id_cache( - # text_id = text_id, - # language = language, - # skip = skip, - # limit = limit, - # cache_type = CacheType.TEXT_VERSIONS - # ) - # if cached_data is not None: - # return cached_data - - root_text = await TextUtils.get_text_detail_by_id(text_id=text_id) - group_id = root_text.group_id - texts = await get_texts_by_group_id(group_id=group_id, skip=skip, limit=limit) - filtered_text_on_root_and_version = TextUtils.filter_text_on_root_and_version(texts=texts, language=language) - root_text = filtered_text_on_root_and_version[TextType.ROOT_TEXT.value] - versions = filtered_text_on_root_and_version[TextTypes.VERSIONS.value] - versions_table_of_content_id_dict: Dict[str, List[str]] = await _get_table_of_content_by_version_text_id(versions=versions) - list_of_version = _get_list_of_text_version_response_model(versions=versions, versions_table_of_content_id_dict=versions_table_of_content_id_dict) - response = TextVersionResponse( - text=root_text, - versions=list_of_version - ) - - await set_text_versions_by_group_id_cache( - text_id = text_id, - language = language, - skip = skip, - limit = limit, - cache_type = CacheType.TEXT_VERSIONS, - data = response - ) - - return response - - -async def create_new_text( - create_text_request: CreateTextRequest, - token: str -) -> TextDTO: - is_valid_user = validate_user_exists(token=token) - if is_valid_user: - valid_group = await validate_group_exists(group_id=create_text_request.group_id) - if not valid_group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.GROUP_NOT_FOUND_MESSAGE) - new_text = await create_text(create_text_request=create_text_request) - return TextDTO( - id=str(new_text.id), - pecha_text_id=str(new_text.pecha_text_id), - title=new_text.title, - language=new_text.language, - group_id=new_text.group_id, - type=new_text.type, - is_published=new_text.is_published, - created_date=new_text.created_date, - updated_date=new_text.updated_date, - published_date=new_text.published_date, - published_by=new_text.published_by, - categories=new_text.categories, - views=new_text.views, - source_link=new_text.source_link, - ranking=new_text.ranking, - license=new_text.license - ) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE) - - async def create_table_of_content(table_of_content_request: TableOfContent, token: str): is_valid_user = validate_user_exists(token=token) - if is_valid_user: await TextUtils.validate_text_exists(text_id=table_of_content_request.text_id) new_table_of_content = await get_table_of_content_by_type(table_of_content=table_of_content_request) @@ -399,100 +112,14 @@ async def create_table_of_content(table_of_content_request: TableOfContent, toke raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE) -# PRIVATE FUNCTIONS - -async def get_table_of_content_by_type(table_of_content: TableOfContent): - if table_of_content.type == TableOfContentType.TEXT: - new_table_of_content = await replace_pecha_segment_id_with_segment_id(table_of_content=table_of_content) - else: - new_table_of_content = table_of_content - - return new_table_of_content - - -async def replace_pecha_segment_id_with_segment_id(table_of_content: TableOfContent) -> TableOfContent: - text_segments = await get_segments_by_text_id(text_id=table_of_content.text_id) - segments_dict = {segment.pecha_segment_id: segment.id for segment in text_segments} - - new_toc = TableOfContent( - text_id=table_of_content.text_id, - type=table_of_content.type, - sections=[] - ) - new_sections = [] - for section in table_of_content.sections: - new_segments = [] - for segment in section.segments: - # db_segment = await Segment.get_segment_by_pecha_segment_id(pecha_segment_id=segment.pecha_segment_id) - new_segments.append( - TextSegment( - segment_id=str(segments_dict[segment.segment_id]), - segment_number=segment.segment_number - ) - ) - new_section = Section( - id=section.id, - title=section.title, - section_number=section.section_number, - segments=new_segments - ) - new_sections.append(new_section) - new_toc.sections = new_sections - return new_toc - -async def _mapping_table_of_content( - text: TextDTO, - table_of_content: TableOfContent, - version_id: str, - size: int, - total_segments: int, - current_segment_position: int, - pagination_direction: PaginationDirection, -) -> DetailTableOfContentResponse: - detail_table_of_content = await SegmentUtils.get_mapped_segment_content_for_table_of_content( - table_of_content=table_of_content, - version_id=version_id - ) - detail_table_of_content = DetailTableOfContentResponse( - text_detail=text, - content=detail_table_of_content, - size=size, - pagination_direction=pagination_direction, - current_segment_position=current_segment_position, - total_segments=total_segments - ) - return detail_table_of_content - - -async def _validate_text_detail_request(text_id: str, text_details_request: TextDetailsRequest) -> bool: - # Check if text_id is provided - if text_id is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE - ) - - # Check if valid version_id is provided - if text_details_request.version_id is not None: - await TextUtils.validate_text_exists( - text_id=text_details_request.version_id - ) - - # Check if valid segment_id is provided - if text_details_request.segment_id is not None: - await SegmentUtils.validate_segment_exists( - segment_id=text_details_request.segment_id - ) - - await TextUtils.validate_text_exists(text_id=text_id) - async def get_root_text_by_collection_id(collection_id: str, language: str) -> Optional[tuple[str, str]]: - texts = await get_all_recitation_texts_by_collection(collection_id=collection_id, language=language) grouped_texts = _group_texts_by_group_id(texts=texts, language=language) recitation_text_list = [] for group_texts in grouped_texts.values(): - filter_text_base_on_group_id_type = await TextUtils.filter_text_base_on_group_id_type_and_language_preference(texts=group_texts, language=language) + filter_text_base_on_group_id_type = await TextUtils.filter_text_base_on_group_id_type_and_language_preference( + texts=group_texts, language=language + ) root_text = filter_text_base_on_group_id_type[TextType.ROOT_TEXT.value] if root_text is None: continue @@ -500,112 +127,6 @@ async def get_root_text_by_collection_id(collection_id: str, language: str) -> O return RecitationsResponse(recitations=recitation_text_list) -def _group_texts_by_group_id(texts: List[TextDTO], language: str|None = None) -> Dict[str, List[TextDTO]]: - texts_by_group_id = {} - for text in texts: - group_id = str(text.group_id) - if group_id not in texts_by_group_id: - texts_by_group_id[group_id] = [] - texts_by_group_id[group_id].append(text) - - # Sort texts within each group by language preference - for group_id in texts_by_group_id: - texts_by_group_id[group_id].sort( - key=lambda text: TextUtils.get_language_priority(text.language, language) - ) - - return texts_by_group_id - - -async def _get_texts_by_collection_id(collection_id: str, language: str, skip: int, limit: int) -> Tuple[List[TextDTO], int]: - texts = await get_all_texts_by_collection(collection_id=collection_id) - group_id_set = set() - for text in texts: - group_id_set.add(text.group_id) - total_unique_group_ids = len(group_id_set) - texts.sort( - key=lambda text: TextUtils.get_language_priority(text.language, language) - ) - track_skip = 0 - track_limit = 0 - text_list = [] - visited_group_ids = set() - for text in texts: - if text.group_id in visited_group_ids: - continue - elif track_skip < skip: - track_skip += 1 - continue - if text is not None: - text_list.append(TextDTO( - id=str(text.id), - pecha_text_id=str(text.pecha_text_id), - title=text.title, - language=text.language, - group_id=text.group_id, - type="root_text", - is_published=text.is_published, - created_date=text.created_date, - updated_date=text.updated_date, - published_date=text.published_date, - published_by=text.published_by, - )) - track_limit += 1 - if track_limit >= limit: - break; - visited_group_ids.add(text.group_id) - return text_list, total_unique_group_ids - - -async def _get_table_of_content_by_version_text_id(versions: List[TextDTO]) -> Dict[str, List[str]]: - versions_table_of_content_id_dict = {} - for version in versions: - list_of_table_of_contents = await get_contents_by_id(text_id=str(version.id)) - list_of_table_of_contents_ids = [] - for table_of_content in list_of_table_of_contents: - list_of_table_of_contents_ids.append(str(table_of_content.id)) - versions_table_of_content_id_dict[str(version.id)] = list_of_table_of_contents_ids - return versions_table_of_content_id_dict - - -async def get_commentaries_by_text_id(text_id: str, skip: int, limit: int) -> List[TextDTO]: - is_valid_text = await TextUtils.validate_text_exists(text_id=text_id) - if not is_valid_text: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE) - - root_text = await TextUtils.get_text_detail_by_id(text_id=text_id) - group_id = root_text.group_id - - commentaries = await TextUtils.get_commentaries_by_text_type(text_type="commentary", language=root_text.language, skip=skip, limit=limit) - final_commentary = [] - for commentary in commentaries: - if commentary.categories and group_id in commentary.categories: - final_commentary.append(commentary) - return final_commentary - - -def _get_list_of_text_version_response_model(versions: List[TextDTO], versions_table_of_content_id_dict: Dict[str, List[str]]) -> List[TextVersion]: - list_of_version = [ - TextVersion( - id=str(version.id), - title=version.title, - language=version.language, - type=version.type, - group_id=version.group_id, - table_of_contents=versions_table_of_content_id_dict.get(str(version.id), []), - is_published=version.is_published, - created_date=version.created_date, - updated_date=version.updated_date, - published_date=version.published_date, - published_by=version.published_by, - source_link=version.source_link, - ranking=version.ranking, - license=version.license - ) - for version in versions - ] - return list_of_version - async def update_text_details(text_id: str, update_text_request: UpdateTextRequest): is_valid_text = await TextUtils.validate_text_exists(text_id=text_id) if not is_valid_text: @@ -615,317 +136,63 @@ async def update_text_details(text_id: str, update_text_request: UpdateTextReque text_details.title = update_text_request.title text_details.is_published = update_text_request.is_published - # Update the text details in the database updated_text = await update_text_details_by_id(text_id=text_id, update_text_request=update_text_request) - # Update the cache with the new text details try: await update_text_details_cache(text_id=text_id, updated_text_data=updated_text) except Exception as e: - # If cache update fails, log the error but don't fail the entire operation - # Fallback to cache invalidation to ensure consistency logging.exception(f"Failed to update cache for text_id {text_id}") await invalidate_text_cache_on_update(text_id=text_id) return updated_text -async def delete_text_by_text_id(text_id: str): - is_valid_text = await TextUtils.validate_text_exists(text_id=text_id) - if not is_valid_text: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE) - await delete_text_by_id(text_id=text_id) - +# PRIVATE FUNCTIONS +async def get_table_of_content_by_type(table_of_content: TableOfContent): + if table_of_content.type == TableOfContentType.TEXT: + return await replace_pecha_segment_id_with_segment_id(table_of_content=table_of_content) + return table_of_content -def _filter_single_section_(section: Section, wanted_segment_ids: Set[str]) -> Section | None: - kept_segments = [] - for segment in section.segments: - if segment.segment_id in wanted_segment_ids: - kept_segments.append(segment) - - kept_subsections = [] - if section.sections: - for subsection in section.sections: - filtered_subsection = _filter_single_section_(subsection, wanted_segment_ids) - if filtered_subsection is not None: - kept_subsections.append(filtered_subsection) - - has_wanted_segments = len(kept_segments) > 0 - has_valid_subsections = len(kept_subsections) > 0 - - if has_wanted_segments or has_valid_subsections: - new_section = Section( - id=section.id, - title=section.title, - section_number=section.section_number, - parent_id=section.parent_id, - segments=kept_segments, - sections=kept_subsections if kept_subsections else None, - created_date=section.created_date, - updated_date=section.updated_date, - published_date=section.published_date - ) - return new_section - else: - return None -def _generate_paginated_table_of_content_by_segments_( - table_of_content: TableOfContent, - segment_dict: Dict[str, int] -) -> TableOfContent: +async def replace_pecha_segment_id_with_segment_id(table_of_content: TableOfContent) -> TableOfContent: + text_segments = await get_segments_by_text_id(text_id=table_of_content.text_id) + segments_dict = {segment.pecha_segment_id: segment.id for segment in text_segments} - wanted_segment_ids = set(segment_dict.keys()) - - filtered_sections = [] - for section in table_of_content.sections: - filtered_section = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids) - if filtered_section is not None: - filtered_sections.append(filtered_section) - - paginated_table_of_content = TableOfContent( - id=str(table_of_content.id), + new_toc = TableOfContent( text_id=table_of_content.text_id, type=table_of_content.type, - sections=filtered_sections + sections=[] ) - - return paginated_table_of_content - - -def _get_trimmed_segment_dict_(segments_with_position:List[Tuple[str,int]], segment_id: str, direction: PaginationDirection, size: int) -> Dict[str, int]: - - - dict_segment_id_with_position: Dict[str, int] = dict(segments_with_position) - - segment_position = dict_segment_id_with_position.get(segment_id) - 1 - - total_segments = len(segments_with_position) - - if direction == PaginationDirection.NEXT: - trimmed_segments_with_position = segments_with_position[segment_position : min(segment_position + size, total_segments)] - - else: - trimmed_segments_with_position = segments_with_position[max(0, segment_position - size + 1) : segment_position + 1] - - trimmed_segments_with_position = dict(trimmed_segments_with_position) - - return trimmed_segments_with_position - -def _get_segments_with_position_(table_of_content: TableOfContent) -> List[Tuple[str, int]]: - segments_with_position: List[Tuple[str, int]] = [] - position = 1 - - def get_segment_from_section(section: Section): - nonlocal position - - for segment in section.segments: - segments_with_position.append((segment.segment_id, position)) - position += 1 - - if section.sections: - for sub_section in section.sections: - get_segment_from_section(sub_section) - + new_sections = [] for section in table_of_content.sections: - get_segment_from_section(section) - return segments_with_position - - -async def _receive_table_of_content(text_id: str, text_details_request: TextDetailsRequest) -> TableOfContent: - table_of_content = None - if text_details_request.content_id is not None and text_details_request.segment_id is not None: - table_of_content:TableOfContent = await get_table_of_content_by_content_id( - content_id=text_details_request.content_id - ) - elif text_details_request.segment_id is not None: - table_of_contents: List[TableOfContent] = await get_contents_by_id(text_id=text_id) - table_of_content: TableOfContent = _search_table_of_content_where_segment_id_exists(table_of_contents=table_of_contents, segment_id=text_details_request.segment_id) - else: - table_of_content = await get_contents_by_id(text_id=text_id) - segment_id, table_of_content = _get_first_segment_and_table_of_content_(table_of_contents=table_of_content) - text_details_request.segment_id = segment_id - - if table_of_content is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE + new_segments = [ + TextSegment( + segment_id=str(segments_dict[segment.segment_id]), + segment_number=segment.segment_number + ) + for segment in section.segments + ] + new_section = Section( + id=section.id, + title=section.title, + section_number=section.section_number, + segments=new_segments ) - return table_of_content - -def _get_first_segment_and_table_of_content_(table_of_contents: List[TableOfContent]) -> tuple[str | None, TableOfContent | None]: - def find_first_segment(sections: list) -> str | None: - for section in sections: - if section.segments: - return section.segments[0].segment_id - if section.sections: - result = find_first_segment(section.sections) - if result: - return result - return None - - for table_of_content in table_of_contents: - segment_id = find_first_segment(sections=table_of_content.sections) - if segment_id: - return segment_id, table_of_content - return None, None - -def _search_section_(sections: List[Section], segment_id: str) -> bool: - for section in sections: - for segment in section.segments: - if segment.segment_id == segment_id: - return True - - if section.sections: - result = _search_section_(sections=section.sections, segment_id=segment_id) - if result: - return result - - return False - -def _search_table_of_content_where_segment_id_exists(table_of_contents: List[TableOfContent], segment_id: str) -> TableOfContent: - for table_of_content in table_of_contents: - result = _search_section_(sections=table_of_content.sections, segment_id=segment_id) - if result: - return table_of_content - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE - ) - -async def get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request: TextsByPechaTextIdsRequest) -> Optional[List[TextDTO]]: - pecha_text_ids = texts_by_pecha_text_ids_request.pecha_text_ids - texts = await get_texts_by_pecha_text_ids(pecha_text_ids=pecha_text_ids) - return [TextDTO( - id=str(text.id), - pecha_text_id=str(text.pecha_text_id), - title=text.title, - language=text.language, - group_id=text.group_id, - type=text.type, - is_published=text.is_published, - created_date=text.created_date, - updated_date=text.updated_date, - published_date=text.published_date, - published_by=text.published_by, - categories=text.categories, - views=text.views, - source_link=text.source_link, - ranking=text.ranking, - license=text.license - ) for text in texts] - + new_sections.append(new_section) + new_toc.sections = new_sections + return new_toc -async def get_text_languages(text_id: str) -> LanguageResponse: - is_valid_text: bool = await TextUtils.validate_text_exists(text_id=text_id) - if not is_valid_text: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE - ) - - text_detail: TextDTO = await TextUtils.get_text_detail_by_id(text_id=text_id) - group_id: str = text_detail.group_id - - texts = await get_all_texts_by_group_id(group_id=group_id) - - language_counts: Dict[str, int] = {} +def _group_texts_by_group_id(texts: List[TextDTO], language: str | None = None) -> Dict[str, List[TextDTO]]: + texts_by_group_id: Dict[str, List[TextDTO]] = {} for text in texts: - if text.language: - language_counts[text.language] = language_counts.get(text.language, 0) + 1 - - available_languages = [ - AvailableLanguage( - language=lang, - language_code=lang, - version_count=count - ) - for lang, count in language_counts.items() - ] - - return LanguageResponse( - text_id=text_id, - title=text_detail.title, - available_languages=available_languages - ) - + group_id = str(text.group_id) + if group_id not in texts_by_group_id: + texts_by_group_id[group_id] = [] + texts_by_group_id[group_id].append(text) -async def get_language_versions(text_id: str, language: str) -> VersionsResponse: - is_valid_text: bool = await TextUtils.validate_text_exists(text_id=text_id) - if not is_valid_text: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE - ) - - text_detail: TextDTO = await TextUtils.get_text_detail_by_id(text_id=text_id) - group_id: str = text_detail.group_id - - texts: List[TextDTO] = await get_all_texts_by_group_id(group_id=group_id) - - filtered_texts = [text for text in texts if text.language == language] - - versions_table_of_content_id_dict: Dict[str, List[str]] = await _get_table_of_content_by_version_text_id(versions=filtered_texts) - - available_versions = [ - VersionDetail( - id=str(text.id), - title=text.title, - parent_id=None, - priority=None, - language=text.language, - type=text.type, - group_id=text.group_id, - table_of_contents=versions_table_of_content_id_dict.get(str(text.id), []), - is_published=text.is_published, - created_date=text.created_date, - updated_date=text.updated_date, - published_date=text.published_date, - published_by=text.published_by, - source_link=text.source_link, - ranking=text.ranking, - license=text.license, - is_selected=(str(text.id) == text_id) + for group_id in texts_by_group_id: + texts_by_group_id[group_id].sort( + key=lambda text: TextUtils.get_language_priority(text.language, language) ) - for text in filtered_texts - ] - - return VersionsResponse( - text_id=text_id, - language=language, - available_versions=available_versions - ) - - -async def get_version_info(version_id: str) -> VersionDetail: - is_valid_text: bool = await TextUtils.validate_text_exists(text_id=version_id) - if not is_valid_text: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE - ) - - text_detail: TextDTO = await TextUtils.get_text_detail_by_id(text_id=version_id) - - table_of_contents: List[TableOfContent] = await get_contents_by_id(text_id=version_id) - table_of_content_ids = [str(toc.id) for toc in table_of_contents] - - return VersionDetail( - id=str(text_detail.id), - title=text_detail.title, - parent_id=None, - priority=None, - language=text_detail.language, - type=text_detail.type, - group_id=text_detail.group_id, - table_of_contents=table_of_content_ids, - is_published=text_detail.is_published, - created_date=text_detail.created_date, - updated_date=text_detail.updated_date, - published_date=text_detail.published_date, - published_by=text_detail.published_by, - source_link=text_detail.source_link, - ranking=text_detail.ranking, - license=text_detail.license, - is_selected=True - ) + return texts_by_group_id diff --git a/pecha_api/texts/texts_views.py b/pecha_api/texts/texts_views.py deleted file mode 100644 index 5ecbbbaa6..000000000 --- a/pecha_api/texts/texts_views.py +++ /dev/null @@ -1,149 +0,0 @@ -from fastapi import APIRouter, Query, Depends -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from starlette import status - -from typing import Optional, Annotated, List - -from .texts_service import ( - get_table_of_contents_by_text_id, - get_text_versions_by_group_id, - create_new_text, - get_text_by_text_id_or_collection, - get_text_details_by_text_id, - create_table_of_content, - get_text_details_by_text_id, - get_commentaries_by_text_id, - get_titles_and_ids_by_query, - get_text_languages, - get_language_versions, - get_version_info -) -from .texts_response_models import ( - CreateTextRequest, - TableOfContentResponse, - TextDetailsRequest, - TableOfContent, - TextDTO, - TextVersionResponse, - DetailTableOfContentResponse, - TextDetailsRequest, - TitleSearchResult, - LanguageResponse, - VersionsResponse, - VersionDetail -) - -oauth2_scheme = HTTPBearer() -text_router = APIRouter( - prefix="/texts", - tags=["Texts"] -) - - -@text_router.get("", status_code=status.HTTP_200_OK) -async def get_text( - text_id: Optional[str] = Query(default=None), - collection_id: Optional[str] = Query(default=None), - language: str = Query(default=None), - skip: int = Query(default=0), - limit: int = Query(default=10) -): - return await get_text_by_text_id_or_collection( - text_id=text_id, - collection_id=collection_id, - language=language, - skip=skip, - limit=limit - ) - - -@text_router.post( - "", - status_code=status.HTTP_201_CREATED, - summary="Create text", - description="Create a new text resource.", -) -async def create_text( - create_text_request: CreateTextRequest, - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], -) -> TextDTO: - return await create_new_text( - create_text_request=create_text_request, - token=authentication_credential.credentials - ) - - -@text_router.get("/{text_id}/versions", status_code=status.HTTP_200_OK) -async def get_versions( - text_id: str, - language: str = Query(default=None), - skip: int = Query(default=0), - limit: int = Query(default=10) -) -> TextVersionResponse: - return await get_text_versions_by_group_id( - text_id=text_id, - language=language, skip=skip, limit=limit - ) - - -@text_router.get("/{text_id}/languages", status_code=status.HTTP_200_OK) -async def get_languages(text_id: str) -> LanguageResponse: - - return await get_text_languages(text_id=text_id) - - -@text_router.get("/versions/{version_id}/info", status_code=status.HTTP_200_OK) -async def get_version_info_endpoint(version_id: str) -> VersionDetail: - return await get_version_info(version_id=version_id) - - -@text_router.get("/{text_id}/languages/{language}/versions", status_code=status.HTTP_200_OK) -async def get_versions_by_language(text_id: str, language: str) -> VersionsResponse: - return await get_language_versions(text_id=text_id, language=language) - - -@text_router.get("/{text_id}/contents", status_code=status.HTTP_200_OK) -async def get_contents( - text_id: str, - language: str = Query(default=None), - skip: int = Query(default=0), - limit: int = Query(default=10) -) -> TableOfContentResponse: - return await get_table_of_contents_by_text_id(text_id=text_id, language=language, skip=skip, limit=limit) - - -@text_router.post("/{text_id}/details", status_code=status.HTTP_200_OK) -async def get_contents_with_details( - text_id: str, - text_details_request: TextDetailsRequest -) -> DetailTableOfContentResponse: - return await get_text_details_by_text_id(text_id=text_id, text_details_request=text_details_request) - -@text_router.post("/table-of-content", status_code=status.HTTP_200_OK) -async def create_table_of_content_request( - authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], - table_of_content_request: TableOfContent -): - return await create_table_of_content(table_of_content_request=table_of_content_request, token=authentication_credential.credentials) - -@text_router.get("/{text_id}/commentaries", status_code=status.HTTP_200_OK) -async def get_commentaries( - text_id: str, - skip: int = Query(default=0, ge=0), - limit: int = Query(default=10, le=100) -) -> List[TextDTO]: - return await get_commentaries_by_text_id(text_id=text_id, skip=skip, limit=limit) - -@text_router.get("/title-search", status_code=status.HTTP_200_OK) -async def search_titles( - title: Optional[str] = Query(default=None), - author: Optional[str] = Query(default=None), - limit: int = Query(default=20), - offset: int = Query(default=0) -) -> List[TitleSearchResult]: - return await get_titles_and_ids_by_query( - title=title, - author=author, - limit=limit, - offset=offset - ) \ No newline at end of file diff --git a/tests/texts/test_text_cache_service.py b/tests/texts/test_text_cache_service.py index d5d73ea8b..011b466f1 100644 --- a/tests/texts/test_text_cache_service.py +++ b/tests/texts/test_text_cache_service.py @@ -2,14 +2,6 @@ from unittest.mock import patch, Mock, AsyncMock from pecha_api.texts.texts_cache_service import ( - get_text_details_cache, - set_text_details_cache, - get_text_by_text_id_or_collection_cache, - set_text_by_text_id_or_collection_cache, - get_table_of_contents_by_text_id_cache, - set_table_of_contents_by_text_id_cache, - get_text_versions_by_group_id_cache, - set_text_versions_by_group_id_cache, update_text_details_cache, invalidate_text_cache_on_update, set_text_details_by_id_cache, @@ -17,373 +9,13 @@ delete_text_details_by_id_cache ) from pecha_api.texts.texts_response_models import ( - DetailTableOfContent, - DetailSection, - DetailTextSegment, TextDTO, TableOfContent, TableOfContentType, - Section, - TextVersionResponse, - TextsCategoryResponse, - DetailTableOfContentResponse, - TableOfContentResponse ) -from pecha_api.collections.collections_response_models import CollectionModel from pecha_api.cache.cache_enums import CacheType -@pytest.mark.asyncio -async def test_get_text_details_cache_empty_cache(): - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None): - - response = await get_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, cache_type=CacheType.TEXT_DETAIL) - - assert response is None - -@pytest.mark.asyncio -async def test_set_text_details_cache_success(): - mock_text_detail = DetailTableOfContent( - id="id_1", - text_id="text_id_1", - sections=[ - DetailSection( - id=f"id_{i}", - title=f"section_{i}", - section_number=i, - parent_id="parent_id_1", - segments=[ - DetailTextSegment( - segment_id=f"segment_id_{i}", - segment_number=1, - content=f"content_{i}", - translation=None - ) - for i in range(1,6) - ], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - for i in range(1,6) - ] - ) - - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - - await set_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, data=mock_text_detail, cache_type=CacheType.TEXT_DETAIL) - - -@pytest.mark.asyncio -async def test_get_text_details_cache_for_text_details_response(): - mock_cache_data = DetailTableOfContent( - id="id_1", - text_id="text_id_1", - sections=[ - DetailSection( - id=f"id_{i}", - title=f"section_{i}", - section_number=i, - parent_id="parent_id_1", - segments=[ - DetailTextSegment( - segment_id=f"segment_id_{i}", - segment_number=1, - content=f"content_{i}", - translation=None - ) - for i in range(1,6) - ], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - for i in range(1,6) - ] - ) - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data): - response = await get_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, cache_type=CacheType.TEXT_DETAIL) - - assert response is not None - assert isinstance(response, DetailTableOfContent) - assert response.id == "id_1" - assert response.text_id == "text_id_1" - assert len(response.sections) == 5 - assert response.sections[0].id == "id_1" - -@pytest.mark.asyncio -async def test_set_text_details_cache_for_text_details_response(): - mock_cache_data = DetailTableOfContent( - id="id_1", - text_id="text_id_1", - sections=[ - DetailSection( - id=f"id_{i}", - title=f"section_{i}", - section_number=i, - parent_id="parent_id_1", - segments=[ - DetailTextSegment( - segment_id=f"segment_id_{i}", - segment_number=1, - content=f"content_{i}", - translation=None - ) - for i in range(1,6) - ], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - for i in range(1,6) - ] - ) - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - await set_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_DETAIL) - -@pytest.mark.asyncio -async def test_get_text_by_text_id_or_collection_cache_empty_cache(): - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None): - response = await get_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION) - - - assert response is None - -@pytest.mark.asyncio -async def test_get_text_by_text_id_or_collection_cache_for_text_by_text_id_or_collection_response(): - mock_cache_data = TextDTO( - id="id_1", - title="title_1", - language="en", - group_id="group_id_1", - type="type_1", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="published_by_1", - categories=[], - views=0 - ) - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data): - - response = await get_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION) - - - assert response is not None - assert isinstance(response, TextDTO) - assert response.id == "id_1" - -@pytest.mark.asyncio -async def test_set_text_by_text_id_or_collection_cache_for_text_by_text_id_or_collection_response(): - mock_cache_data = TextDTO( - id="id_1", - title="title_1", - language="en", - group_id="group_id_1", - type="type_1", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="published_by_1", - categories=[], - views=0 - ) - - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - await set_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION) - -@pytest.mark.asyncio -async def test_get_table_of_contents_by_text_id_cache_empty_cache(): - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None): - response = await get_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS) - - assert response is None - -@pytest.mark.asyncio -async def test_get_table_of_contents_by_text_id_cache_for_table_of_contents_by_text_id_response(): - mock_cache_data = TableOfContent( - id="table_of_content_id", - text_id="text_id_1", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data): - - response = await get_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS) - - assert response is not None - assert isinstance(response, TableOfContent) - assert response.id == "table_of_content_id" - assert response.text_id == "text_id_1" - assert len(response.sections) == 1 - assert response.sections[0].id == "id_1" - -@pytest.mark.asyncio -async def test_set_table_of_contents_by_text_id_cache_for_table_of_contents_by_text_id_response(): - mock_cache_data = TableOfContent( - id="table_of_content_id", - text_id="text_id_1", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - await set_table_of_contents_by_text_id_cache(text_id="text_id", data=mock_cache_data, language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS) - -@pytest.mark.asyncio -async def test_get_text_versions_by_group_id_cache_empty_cache(): - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None): - response = await get_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_VERSIONS) - - assert response is None - -@pytest.mark.asyncio -async def test_get_text_versions_by_group_id_cache_for_text_versions_by_group_id_response(): - mock_cache_data = TextVersionResponse( - text=TextDTO( - id="id_1", - title="title_1", - language="en", - group_id="group_id_1", - type="type_1", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="published_by_1", - categories=[], - views=0 - ), - versions=[] - ) - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data): - response = await get_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_VERSIONS) - - assert response is not None - assert isinstance(response, TextVersionResponse) - -@pytest.mark.asyncio -async def test_set_text_versions_by_group_id_cache_for_text_versions_by_group_id_response(): - mock_cache_data = TextVersionResponse( - text=TextDTO( - id="id_1", - title="title_1", - language="en", - group_id="group_id_1", - type="type_1", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="published_by_1", - categories=[], - views=0 - ), - versions=[] - ) - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - await set_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_VERSIONS) - - -@pytest.mark.asyncio -async def test_set_text_by_text_id_or_collection_cache_success(): - mock_data = TextsCategoryResponse( - collection=CollectionModel( - id="id_1", - title="title_1", - description="description_1", - language="en", - slug="slug_1", - has_child=False - ), - texts=[], - total=0, - skip=0, - limit=10 - ) - - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - - await set_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, data=mock_data, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION) - -@pytest.mark.asyncio -async def test_set_table_of_contents_by_text_id_cache_success(): - mock_cache_data = TableOfContent( - id="table_of_content_id", - text_id="text_id_1", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - - await set_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS) - -@pytest.mark.asyncio -async def test_set_text_versions_by_group_id_cache_success(): - mock_cache_data = TextVersionResponse( - text=TextDTO( - id="id_1", - title="title_1", - language="en", - group_id="group_id_1", - type="type_1", - is_published=True, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="published_by_1", - categories=[], - views=0 - ), - versions=[] - ) - - with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock): - - await set_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_VERSIONS) - @pytest.mark.asyncio async def test_update_text_details_cache_success(): #Test successful update of text details cache @@ -560,125 +192,6 @@ def mock_generate_hash_key(payload): ] assert kwargs["hash_keys"] == expected_hash_keys -@pytest.mark.asyncio -async def test_get_text_details_cache_with_dict_response(): - #Test get_text_details_cache when cache returns dict and needs conversion# - mock_cache_dict = { - "text_detail": { - "id": "text_id_1", - "title": "Test Title", - "language": "en", - "group_id": "group_id_1", - "type": "root_text", - "is_published": True, - "created_date": "2025-03-16 04:40:54.757652", - "updated_date": "2025-03-16 04:40:54.757652", - "published_date": "2025-03-16 04:40:54.757652", - "published_by": "user_1", - "categories": [], - "views": 0 - }, - "content": { - "id": "id_1", - "text_id": "text_id_1", - "sections": [] - }, - "size": 20, - "pagination_direction": "next", - "current_segment_position": 1, - "total_segments": 100 - } - - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict): - response = await get_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, cache_type=CacheType.TEXT_DETAIL) - - assert response is not None - assert isinstance(response, DetailTableOfContentResponse) - assert response.content.id == "id_1" - -@pytest.mark.asyncio -async def test_get_text_by_text_id_or_collection_cache_with_dict_response(): - #Test get_text_by_text_id_or_collection_cache when cache returns dict and needs conversion# - mock_cache_dict = { - "collection": { - "id": "id_1", - "title": "title_1", - "description": "description_1", - "language": "en", - "slug": "slug_1", - "has_child": False - }, - "texts": [], - "total": 0, - "skip": 0, - "limit": 10 - } - - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict): - response = await get_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION) - - assert response is not None - assert isinstance(response, TextsCategoryResponse) - -@pytest.mark.asyncio -async def test_get_table_of_contents_by_text_id_cache_with_dict_response(): - #Test get_table_of_contents_by_text_id_cache when cache returns dict and needs conversion# - mock_cache_dict = { - "text_detail": { - "id": "text_id_1", - "title": "Test Title", - "language": "en", - "group_id": "group_id_1", - "type": "root_text", - "is_published": True, - "created_date": "2025-03-16 04:40:54.757652", - "updated_date": "2025-03-16 04:40:54.757652", - "published_date": "2025-03-16 04:40:54.757652", - "published_by": "user_1", - "categories": [], - "views": 0 - }, - "contents": [{ - "id": "table_of_content_id", - "text_id": "text_id_1", - "type": "text", - "sections": [] - }] - } - - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict): - response = await get_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS) - - assert response is not None - assert isinstance(response, TableOfContentResponse) - assert response.contents[0].id == "table_of_content_id" - -@pytest.mark.asyncio -async def test_get_text_versions_by_group_id_cache_with_dict_response(): - #Test get_text_versions_by_group_id_cache when cache returns dict and needs conversion# - mock_cache_dict = { - "text": { - "id": "id_1", - "title": "title_1", - "language": "en", - "group_id": "group_id_1", - "type": "type_1", - "is_published": True, - "created_date": "2025-03-16 04:40:54.757652", - "updated_date": "2025-03-16 04:40:54.757652", - "published_date": "2025-03-16 04:40:54.757652", - "published_by": "published_by_1", - "categories": [], - "views": 0 - }, - "versions": [] - } - - with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict): - response = await get_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_VERSIONS) - - assert response is not None - assert isinstance(response, TextVersionResponse) @pytest.mark.asyncio async def test_set_text_details_by_id_cache_success(): diff --git a/tests/texts/test_texts_service.py b/tests/texts/test_texts_service.py index d8097484d..c491c665f 100644 --- a/tests/texts/test_texts_service.py +++ b/tests/texts/test_texts_service.py @@ -1,375 +1,27 @@ -from unittest.mock import AsyncMock, patch, MagicMock, Mock -import httpx +from unittest.mock import AsyncMock, patch from fastapi import HTTPException from uuid import uuid4 -from pecha_api.collections.collections_response_models import CollectionModel import pytest from pecha_api.texts.texts_service import ( - create_new_text, - get_text_versions_by_group_id, - get_text_by_text_id_or_collection, create_table_of_content, - get_table_of_contents_by_text_id, - get_text_details_by_text_id, update_text_details, - remove_table_of_content_by_text_id, - delete_text_by_text_id, get_table_of_content_by_type, - _validate_text_detail_request, get_root_text_by_collection_id, - get_commentaries_by_text_id, replace_pecha_segment_id_with_segment_id, - get_text_by_pecha_text_ids_service, - get_titles_and_ids_by_query, - get_text_languages, - get_language_versions, - get_version_info ) -from pecha_api.terms.terms_response_models import TermsModel from pecha_api.texts.texts_response_models import ( - CreateTextRequest, TextDTO, - TextVersion, TableOfContent, TableOfContentType, Section, TextSegment, - TableOfContentResponse, - TextDetailsRequest, - DetailTableOfContentResponse, - DetailTableOfContent, - DetailSection, - DetailTextSegment, - Translation, UpdateTextRequest, - TextDTOResponse, - TextVersionResponse, - TextsCategoryResponse, TextsByPechaTextIdsRequest, - LanguageResponse, - AvailableLanguage, - VersionDetail, - VersionsResponse ) from pecha_api.recitations.recitations_response_models import RecitationDTO, RecitationsResponse -from pecha_api.texts.texts_enums import TextType, PaginationDirection, LANGUAGE_ORDERS - from pecha_api.error_contants import ErrorConstants -from typing import List - -@pytest.mark.asyncio -async def test_get_text_by_text_id_or_collection_without_collection_id_success(): - text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - collection_id = None - with patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock) as mock_get_text_detail_by_id, \ - patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=None): - mock_get_text_detail_by_id.return_value = TextDTO( - id=text_id, - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - language="bo", - group_id="group_id_1", - type="commentary", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - - response = await get_text_by_text_id_or_collection(text_id=text_id, collection_id=collection_id) - - assert response is not None - assert isinstance(response, TextDTO) - assert response.id == text_id - -@pytest.mark.asyncio -async def test_get_text_by_collection_id(): - mock_collection = CollectionModel( - id="id_1", - title="སྤྱོད་འཇུག", - description="དུས་རབས་ ༨ པའི་ནང་སློབ་དཔོན་ཞི་བ་ལྷས་མཛད་པའི་རྩ་བ་དང་དེའི་འགྲེལ་བ་སོགས།", - language="bo", - slug="bodhicaryavatara", - has_child=False - ) - - # Use valid UUID for group_id - valid_group_id = "123e4567-e89b-12d3-a456-426614174001" - - mock_texts_by_category = [ - TextDTO( - id="a48c0814-ce56-4ada-af31-f74b179b52a9", - title="སྤྱོད་འཇུག་དཀའ་འགྲེལ།", - language="bo", - group_id=valid_group_id, - type="commentary", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0, - pecha_text_id="pecha_1" - ), - TextDTO( - id="032b9a5f-0712-40d8-b7ec-73c8c94f1c15", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - language="bo", - group_id=valid_group_id, - type="version", - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha", - categories=[], - views=0, - pecha_text_id="pecha_2" - ) - ] - - with patch('pecha_api.texts.texts_service.get_collection', new_callable=AsyncMock, return_value=mock_collection), \ - patch('pecha_api.texts.texts_service.get_all_texts_by_collection', new_callable=AsyncMock) as mock_get_all_texts, \ - patch('pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None), \ - patch('pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None): - mock_get_all_texts.return_value = [mock_texts_by_category[1]] - response = await get_text_by_text_id_or_collection(text_id=None, collection_id="id_1", language="bo", skip=0, limit=10) - assert response is not None - assert response.collection is not None - collection: CollectionModel = response.collection - assert collection.id == "id_1" - assert collection.slug == "bodhicaryavatara" - assert response.texts is not None - texts: List[TextDTO] = response.texts - assert len(texts) == 1 - assert texts[0] is not None - assert isinstance(texts[0], TextDTO) - assert texts[0].id == mock_texts_by_category[1].id - assert texts[0].title == mock_texts_by_category[1].title - assert texts[0].language == mock_texts_by_category[1].language - assert texts[0].type == "root_text" - assert response.total == 1 - assert response.skip == 0 - assert response.limit == 10 - - -@pytest.mark.asyncio -async def test_get_versions_by_group_id(): - text_detail = TextDTO( - id="id_1", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - group_id="group_id_1", - language="bo", - type="version", - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha", - categories=[], - views=0 - ) - texts_by_group_id = [ - TextDTO( - id="text_id_1", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - group_id="group_id_1", - language="bo", - type="version", - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_2", - title="The Way of the Bodhisattva", - language="en", - group_id="group_id_1", - type="version", - is_published=True, - created_date="2025-03-20 09:28:28.076920", - updated_date="2025-03-20 09:28:28.076934", - published_date="2025-03-20 09:28:28.076938", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_3", - title="शबोधिचर्यावतार", - language="sa", - group_id="group_id_1", - type="version", - is_published=True, - created_date="2025-03-20 09:29:51.154697", - updated_date="2025-03-20 09:29:51.154708", - published_date="2025-03-20 09:29:51.154712", - published_by="pecha", - categories=[], - views=0 - ) - ] - mock_table_of_content = TableOfContent( - id="table_of_content_id", - text_id="text_id_1", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - language = "en" - with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \ - patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\ - patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\ - patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id: - mock_text_detail.return_value = text_detail - mock_get_texts_by_group_id.return_value = texts_by_group_id - mock_get_contents_by_id.return_value = [mock_table_of_content] - response = await get_text_versions_by_group_id(text_id="id_1",language=language, skip=0, limit=10) - assert response is not None - assert response.text is not None - assert isinstance(response.text, TextDTO) - assert response.text.type == "version" - assert response.text.language == language - assert response.text.id == "text_id_2" - assert response.versions is not None - assert len(response.versions) == 2 - assert response.versions[0] is not None - assert isinstance(response.versions[0], TextVersion) - assert response.versions[0].id == "text_id_1" - for version in response.versions: - assert isinstance(version, TextVersion) - assert version.type == "version" - - - -@pytest.mark.asyncio -async def test_create_new_text(): - text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - title = "བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།" - language = "bo" - is_published = True - group_id = "67dd22a8d9f06ab28feedc90" - created_date = "2025-03-16 04:40:54.757652" - updated_date = "2025-03-16 04:40:54.757652" - published_date = "2025-03-16 04:40:54.757652" - published_by = "pecha" - type_ = TextType.VERSION - categories = [] - with patch('pecha_api.texts.texts_service.validate_user_exists', return_value=True), \ - patch('pecha_api.texts.texts_service.create_text', new_callable=AsyncMock) as mock_create_text,\ - patch('pecha_api.texts.texts_service.validate_group_exists', new_callable=AsyncMock) as mock_validate_group_exists: - mock_create_text.return_value = AsyncMock( - id=text_id, - pecha_text_id="test_pecha_id", - title=title, - language=language, - is_published=is_published, - group_id=group_id, - created_date=created_date, - updated_date=updated_date, - published_date=published_date, - published_by=published_by, - type=type_, - categories=categories, - views=0, - source_link="https://test-source.com", - ranking=1, - license="CC0" - ) - mock_validate_group_exists.return_value = True - response = await create_new_text( - create_text_request=CreateTextRequest( - pecha_text_id="test_pecha_id", - title=title, - language=language, - group_id=group_id, - published_by=published_by, - type=type_, - categories=categories, - source_link="https://test-source.com", - ranking=1, - license="CC0" - ), - token="admin" - ) - assert response is not None - assert isinstance(response, TextDTO) - assert response.id == text_id - assert response.title == title - assert response.language == language - assert response.type == type_.value - assert response.is_published == is_published - assert response.created_date == created_date - assert response.updated_date == updated_date - assert response.published_date == published_date - assert response.published_by == published_by - assert response.categories == categories - -@pytest.mark.asyncio -async def test_create_new_text_invalid_group_id(): - with patch("pecha_api.texts.texts_service.validate_user_exists", return_value=True), \ - patch("pecha_api.texts.texts_service.validate_group_exists", return_value=False): - with pytest.raises(HTTPException) as exc_info: - await create_new_text( - create_text_request=CreateTextRequest( - pecha_text_id="test_pecha_id", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - language="bo", - group_id="invalid_group_id", - published_by="pecha", - type=TextType.VERSION, - categories=[], - source_link="https://test-source.com", - ranking=1, - license="CC0" - ), - token="user" - ) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.GROUP_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_create_new_text_invalid_user(): - with patch("pecha_api.texts.texts_service.validate_user_exists", return_value=False): - with pytest.raises(HTTPException) as exc_info: - await create_new_text( - create_text_request=CreateTextRequest( - pecha_text_id="test_pecha_id", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - language="bo", - group_id="67dd22a8d9f06ab28feedc90", - published_by="pecha", - type=TextType.VERSION, - categories=[], - source_link="https://test-source.com", - ranking=1, - license="CC0" - ), - token="user" - ) - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE @pytest.mark.asyncio async def test_create_table_of_content_success(): @@ -488,305 +140,6 @@ async def test_create_table_of_content_invalid_segment(): assert ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE in exc_info.value.detail assert str(segment_ids) in exc_info.value.detail -@pytest.mark.asyncio -async def test_get_table_of_contents_by_text_id_success(): - text_id = "text_id_1" - language = "bo" - skip = 0 - limit = 10 - - mock_text_detail = TextDTO( - id=text_id, - title="text_1", - language=language, - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ) - mock_group_texts = [ - TextDTO( - id="text_id_1", - title="text_1", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_2", - title="text_2", - language="en", - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ) - ] - - table_of_contents = [ - TableOfContent( - id="id_1", - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[ - TextSegment( - segment_id="seg_1", - segment_number=1 - ) - ], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - ] - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents): - - response = await get_table_of_contents_by_text_id( - text_id=text_id, - language=language, - skip=skip, - limit=limit - ) - - assert response is not None - assert isinstance(response, TableOfContentResponse) - assert response.text_detail is not None - assert isinstance(response.text_detail, TextDTO) - assert response.text_detail.id == mock_text_detail.id - assert response.text_detail.language == language - assert response.contents is not None - assert len(response.contents) == 1 - assert response.contents[0] is not None - assert isinstance(response.contents[0], TableOfContent) - assert response.contents[0].id == table_of_contents[0].id - assert response.contents[0].text_id == table_of_contents[0].text_id - assert response.contents[0].sections is not None - -@pytest.mark.asyncio -async def test_get_table_of_contents_by_text_id_success_language_is_none(): - text_id = "text_id_1" - language = "en" - skip = 0 - limit = 10 - - mock_text_detail = TextDTO( - id=text_id, - title="text_1", - language=language, - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ) - mock_group_texts = [ - TextDTO( - id="text_id_1", - title="text_1", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_2", - title="text_2", - language="en", - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ) - ] - - table_of_contents = [ - TableOfContent( - id="id_1", - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[ - TextSegment( - segment_id="seg_1", - segment_number=1 - ) - ], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - ] - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents): - - response = await get_table_of_contents_by_text_id( - text_id=text_id, - language=None, - skip=skip, - limit=limit - ) - - assert response is not None - assert isinstance(response, TableOfContentResponse) - assert response.text_detail is not None - assert isinstance(response.text_detail, TextDTO) - assert response.text_detail.language == language - assert response.contents is not None - assert len(response.contents) == 1 - assert response.contents[0] is not None - assert isinstance(response.contents[0], TableOfContent) - -@pytest.mark.asyncio -async def test_get_table_of_contents_by_text_id_root_text_is_none(): - """Test that HTTPException is raised when no root text can be determined after filtering""" - from pecha_api.constants import Constants - - text_id = "text_id_1" - language = "zh" # Requesting Chinese but no Chinese text exists - skip = 0 - limit = 10 - - mock_text_detail = TextDTO( - id=text_id, - title="text_1", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ) - - # Mock texts that are all in excluded_text_ids so root_text becomes None - excluded_id = "excluded_text_id_test" - mock_group_texts = [ - TextDTO( - id=excluded_id, - title="text_1", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652", - published_by="pecha", - categories=[], - views=0 - ) - ] - - table_of_contents = [ - TableOfContent( - id="id_1", - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - ] - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents), \ - patch("pecha_api.constants.Constants.excluded_text_ids", [excluded_id]): - - with pytest.raises(HTTPException) as exc_info: - await get_table_of_contents_by_text_id( - text_id=text_id, - language=language, - skip=skip, - limit=limit - ) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_get_table_of_contents_by_text_id_invalid_text(): - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_table_of_contents_by_text_id( - text_id="id_1", - language="en", - skip=0, - limit=10 - ) - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - - @pytest.mark.asyncio async def test_update_text_details_success(): mock_text_details = TextDTO( @@ -825,621 +178,20 @@ async def test_update_text_details_invalid_text_id(): assert exec_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE @pytest.mark.asyncio -async def test_delete_table_of_content_success(): - with patch("pecha_api.texts.texts_service.delete_table_of_content_by_text_id", new_callable=AsyncMock), \ - patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True): - response = await remove_table_of_content_by_text_id(text_id="text_id_1") - assert response is not None - -@pytest.mark.asyncio -async def test_delete_table_of_content_invalid_text_id(): - with patch("pecha_api.texts.texts_service.delete_table_of_content_by_text_id", new_callable=AsyncMock), \ - patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exec_info: - await remove_table_of_content_by_text_id(text_id="invalid_id") - assert exec_info.value.status_code == 404 - assert exec_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_delete_text_by_text_id_success(): - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.delete_text_by_id", new_callable=AsyncMock): - response = await delete_text_by_text_id(text_id="text_id_1") - assert response is None - -@pytest.mark.asyncio -async def test_delete_text_by_text_id_invalid_text_id(): - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await delete_text_by_text_id(text_id="invalid_text_id") - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_get_text_details_by_text_id_with_text_id_content_id_segment_id_success(): - text_id = "text_id_1" - content_id = "content_id_1" - segment_id = "segment_id_1" - mock_text_detail = TextDTO( - id=text_id, - title="text_title", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=[], - views=0 - ) - mock_table_of_content = TableOfContent( - id=content_id, - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment( - segment_id="segment_id_1", - segment_number=1 - ), - TextSegment( - segment_id="segment_id_2", - segment_number=2 - ), - TextSegment( - segment_id="segment_id_3", - segment_number=3 - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - mock_mapped_table_of_content = DetailTableOfContent( - id=content_id, - text_id=text_id, - sections=[ - DetailSection( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - DetailTextSegment( - segment_id="segment_id_1", - segment_number=1, - content="segment_content_1", - translation=None - ), - DetailTextSegment( - segment_id="segment_id_2", - segment_number=2, - content="segment_content_2", - translation=None - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - - with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_table_of_content_by_content_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_content): - - response = await get_text_details_by_text_id( - text_id=text_id, - text_details_request=TextDetailsRequest( - content_id=content_id, - segment_id=segment_id, - size=2, - direction=PaginationDirection.NEXT - ) - ) - - assert response is not None - assert response.text_detail is not None - assert isinstance(response.text_detail, TextDTO) - assert response.text_detail.id == mock_text_detail.id - assert response.content is not None - assert isinstance(response.content, DetailTableOfContent) - assert response.content.sections is not None - assert len(response.content.sections) == 1 - assert response.content.sections[0] is not None - assert isinstance(response.content.sections[0], DetailSection) - section = response.content.sections[0] - assert section.segments is not None - assert len(section.segments) == 2 - assert section.segments[0].segment_id == "segment_id_1" - assert section.segments[1].segment_id == "segment_id_2" - assert response.pagination_direction == PaginationDirection.NEXT - -@pytest.mark.asyncio -async def test_get_text_details_by_text_id_with_text_id_content_id_segment_id_previous_success(): - text_id = "text_id_1" - content_id = "content_id_1" - segment_id = "segment_id_1" - mock_text_detail = TextDTO( - id=text_id, - title="text_title", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=[], - views=0 - ) - mock_table_of_content = TableOfContent( - id=content_id, - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment( - segment_id="segment_id_1", - segment_number=1 - ), - TextSegment( - segment_id="segment_id_2", - segment_number=2 - ), - TextSegment( - segment_id="segment_id_3", - segment_number=3 - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - mock_mapped_table_of_content = DetailTableOfContent( - id=content_id, - text_id=text_id, - sections=[ - DetailSection( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - DetailTextSegment( - segment_id="segment_id_1", - segment_number=1, - content="segment_content_1", - translation=None - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - - with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_table_of_content_by_content_id", new_callable=AsyncMock, return_value=mock_table_of_content), \ - patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_content): - - response = await get_text_details_by_text_id( - text_id=text_id, - text_details_request=TextDetailsRequest( - content_id=content_id, - segment_id=segment_id, - size=2, - direction=PaginationDirection.PREVIOUS - ) - ) - - assert response is not None - assert response.text_detail is not None - assert isinstance(response.text_detail, TextDTO) - assert response.text_detail.id == mock_text_detail.id - assert response.content is not None - assert isinstance(response.content, DetailTableOfContent) - assert response.content.sections is not None - assert len(response.content.sections) == 1 - assert response.content.sections[0] is not None - assert isinstance(response.content.sections[0], DetailSection) - section = response.content.sections[0] - assert section.segments is not None - assert len(section.segments) == 1 - assert section.segments[0].segment_id == "segment_id_1" - assert response.pagination_direction == PaginationDirection.PREVIOUS - - -@pytest.mark.asyncio -async def test_get_text_details_by_text_id_with_segment_id_only_success(): - text_id = "text_id_1" - segment_id = "segment_id_1" - mock_text_detail = TextDTO( - id=text_id, - title="text_title", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=[], - views=0 - ) - mock_table_of_contents = [ - TableOfContent( - id=f"content_id_{i}", - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment( - segment_id=f"segment_id_{i}", - segment_number=1 - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - for i in range(1,11) - ] - mock_mapped_table_of_contents = DetailTableOfContent( - id="content_id_1", - text_id=text_id, - sections=[ - DetailSection( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - DetailTextSegment( - segment_id=f"segment_id_1", - segment_number=1, - content="segment_content_1", - translation=None - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - - with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents), \ - patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_contents): - - response = await get_text_details_by_text_id( - text_id=text_id, - text_details_request=TextDetailsRequest( - segment_id=segment_id, - size=2, - direction=PaginationDirection.NEXT - ) - ) - - assert response is not None - assert response.text_detail is not None - assert isinstance(response.text_detail, TextDTO) - assert response.text_detail.id == mock_text_detail.id - assert response.content is not None - assert isinstance(response.content, DetailTableOfContent) - assert response.content.sections is not None - assert len(response.content.sections) == 1 - assert response.content.sections[0] is not None - assert isinstance(response.content.sections[0], DetailSection) - section = response.content.sections[0] - assert section.segments is not None - assert len(section.segments) == 1 - assert section.segments[0].segment_id == segment_id - assert response.pagination_direction == PaginationDirection.NEXT - - - -@pytest.mark.asyncio -async def test_get_text_details_by_text_id_with_content_id_only_success(): - text_id = "text_id_1" - content_id = "content_id_1" - mock_text_detail = TextDTO( - id=text_id, - title="text_title", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=[], - views=0 - ) - mock_table_of_contents = [ - TableOfContent( - id=f"content_id_{i}", - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment( - segment_id=f"segment_id_{i}", - segment_number=1 - ) - ], - sections=[ - Section( - id="section_id_2", - title="section_title_2", - section_number=2, - parent_id="parent_id_2", - segments=[ - TextSegment( - segment_id=f"1_segment_id_{i}", - segment_number=1 - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - for i in range(1,11) - ] - mock_mapped_table_of_contents = DetailTableOfContent( - id="content_id_1", - text_id=text_id, - sections=[ - DetailSection( - id="section_id_1", - title="section_title", - section_number=1, - parent_id="parent_id_1", - segments=[ - DetailTextSegment( - segment_id=f"segment_id_1", - segment_number=1, - content="segment_content_1", - translation=None - ) - ], - sections=[ - DetailSection( - id="section_id_2", - title="section_title_2", - section_number=2, - parent_id="parent_id_2", - segments=[ - DetailTextSegment( - segment_id=f"1_segment_id_1", - segment_number=1, - content="segment_content_1", - translation=None - ) - ], - sections=[], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ], - created_date="created_date", - updated_date="updated_date", - published_date="published_date" - ) - ] - ) - - with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents), \ - patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_contents): - - response = await get_text_details_by_text_id( - text_id=text_id, - text_details_request=TextDetailsRequest( - content_id=content_id, - size=2, - direction=PaginationDirection.NEXT - ) - ) - - assert response is not None - assert response.text_detail is not None - assert isinstance(response.text_detail, TextDTO) - assert response.text_detail.id == mock_text_detail.id - assert response.content is not None - assert isinstance(response.content, DetailTableOfContent) - assert response.content.id == content_id - assert response.content.sections is not None - assert len(response.content.sections) == 1 - assert response.content.sections[0] is not None - assert isinstance(response.content.sections[0], DetailSection) - section = response.content.sections[0] - assert section.segments is not None - assert section.sections is not None - assert len(section.segments) + len(section.sections[0].segments) == 2 - assert section.segments[0].segment_id == "segment_id_1" - assert response.pagination_direction == PaginationDirection.NEXT - - -@pytest.mark.asyncio -async def test_validate_text_detail_request_success(): - text_id = "text_id_1" - content_id = "content_id_1" - version_id = "version_id_1" - segment_id = "segment_id_1" - section_id = "section_id_1" - size = 20 - direction = PaginationDirection.NEXT - - text_details_request = TextDetailsRequest( - content_id=content_id, - version_id=version_id, - segment_id=segment_id, - section_id=section_id, - size=size, - direction=direction - ) - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True): - - response = await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request) - -@pytest.mark.asyncio -async def test_validate_text_detail_request_text_id_is_none(): - text_id = None - content_id = "content_id_1" - version_id = "version_id_1" - segment_id = "segment_id_1" - section_id = "section_id_1" - size = 20 - direction = PaginationDirection.NEXT - - text_details_request = TextDetailsRequest( - content_id=content_id, - version_id=version_id, - segment_id=segment_id, - section_id=section_id, - size=size, - direction=direction - ) - - with pytest.raises(HTTPException) as e: - await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request) - - assert e.value.status_code == 400 - assert e.value.detail == ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_validate_text_detail_request_invalid_version_id(): - text_id = None - content_id = "content_id_1" - version_id = "invalid_version_id_1" - segment_id = "segment_id_1" - section_id = "section_id_1" - size = 20 - direction = PaginationDirection.NEXT - - text_details_request = TextDetailsRequest( - content_id=content_id, - version_id=version_id, - segment_id=segment_id, - section_id=section_id, - size=size, - direction=direction - ) - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as e: - await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request) - assert e.value.status_code == 400 - assert e.value.detail == ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE - -@pytest.mark.asyncio -async def test_validate_text_detail_request_invalid_segment_id(): - text_id = None - content_id = "content_id_1" - version_id = "version_id_1" - segment_id = "invalid_segment_id_1" - section_id = "section_id_1" - size = 20 - direction = PaginationDirection.NEXT - - text_details_request = TextDetailsRequest( - content_id=content_id, - version_id=version_id, - segment_id=segment_id, - section_id=section_id, - size=size, - direction=direction - ) +async def test_get_root_text_by_collection_id_success_with_root_text(): + """Test get_root_text_by_collection_id when root text is found""" + collection_id = str(uuid4()) + language = "bo" + text_id_1 = str(uuid4()) + text_id_2 = str(uuid4()) + group_id_1 = str(uuid4()) - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False): - - with pytest.raises(HTTPException) as e: - await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request) - - assert e.value.status_code == 400 - assert e.value.detail == ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE - - -@pytest.mark.asyncio -async def test_get_versions_by_group_id_language_is_none(): - text_detail = TextDTO( - id="id_1", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - group_id="group_id_1", - language="bo", - type="version", - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha", - categories=[], - views=0 - ) - texts_by_group_id = [ + mock_texts = [ TextDTO( - id="text_id_1", + id=text_id_1, title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - group_id="group_id_1", language="bo", + group_id=group_id_1, type="version", is_published=True, created_date="2025-03-20 09:26:16.571522", @@ -1450,10 +202,10 @@ async def test_get_versions_by_group_id_language_is_none(): views=0 ), TextDTO( - id="text_id_2", + id=text_id_2, title="The Way of the Bodhisattva", language="en", - group_id="group_id_1", + group_id=group_id_1, type="version", is_published=True, created_date="2025-03-20 09:28:28.076920", @@ -1462,230 +214,31 @@ async def test_get_versions_by_group_id_language_is_none(): published_by="pecha", categories=[], views=0 - ), - TextDTO( - id="text_id_3", - title="शबोधिचर्यावतार", - language="sa", - group_id="group_id_1", - type="version", - is_published=True, - created_date="2025-03-20 09:29:51.154697", - updated_date="2025-03-20 09:29:51.154708", - published_date="2025-03-20 09:29:51.154712", - published_by="pecha", - categories=[], - views=0 ) ] - mock_table_of_content = TableOfContent( - id="table_of_content_id", - text_id="text_id_1", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="id_1", - title="section_1", - section_number=1, - parent_id="id_1", - segments=[], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - language = "en" - with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \ - patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\ - patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\ - patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id: - mock_text_detail.return_value = text_detail - mock_get_texts_by_group_id.return_value = texts_by_group_id - mock_get_contents_by_id.return_value = [mock_table_of_content] - response = await get_text_versions_by_group_id(text_id="id_1",language=None, skip=0, limit=10) - assert response is not None - assert response.text is not None - assert isinstance(response.text, TextDTO) - assert response.text.type == "version" - assert response.text.language == language - assert response.text.id == "text_id_2" - assert response.versions is not None - assert len(response.versions) == 2 - assert response.versions[0] is not None - assert isinstance(response.versions[0], TextVersion) - assert response.versions[0].id == "text_id_1" - for version in response.versions: - assert isinstance(version, TextVersion) - assert version.type == "version" + + mock_filtered_result = { + "root_text": mock_texts[0], + "versions": [mock_texts[1]] + } + + with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \ + patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter: -@pytest.mark.asyncio -async def test_get_versions_by_group_id_cache_data_is_not_none(): - """Test get_text_versions_by_group_id returns cached data when available""" - text_detail = TextDTO( - id="id_1", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - group_id="group_id_1", - language="bo", - type="version", - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha", - categories=[], - views=0 - ) - texts_by_group_id = [ - TextDTO( - id="text_id_1", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - group_id="group_id_1", - language="bo", - type="version", - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_2", - title="The Way of the Bodhisattva", - language="en", - group_id="group_id_1", - type="version", - is_published=True, - created_date="2025-03-20 09:28:28.076920", - updated_date="2025-03-20 09:28:28.076934", - published_date="2025-03-20 09:28:28.076938", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_3", - title="शबोधिचर्यावतार", - language="sa", - group_id="group_id_1", - type="version", - is_published=True, - created_date="2025-03-20 09:29:51.154697", - updated_date="2025-03-20 09:29:51.154708", - published_date="2025-03-20 09:29:51.154712", - published_by="pecha", - categories=[], - views=0 - ) - ] - cache_text_version = TextVersionResponse( - text=text_detail, - versions=[ - TextVersion( - id="text_id_1", - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - parent_id=None, - priority=None, - language="bo", - type="version", - group_id="group_id_1", - table_of_contents=[], - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha" - ) - ] - ) - language = "en" - - # Mock TextUtils.get_text_detail_by_id which is called before cache check - # Note: The actual implementation first gets the root text, then checks cache - with patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=text_detail), \ - patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=texts_by_group_id), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]), \ - patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock): - - response = await get_text_versions_by_group_id(text_id="id_1", language=language, skip=0, limit=10) - - assert response is not None - assert isinstance(response, TextVersionResponse) - assert response.text is not None - assert isinstance(response.text, TextDTO) - assert response.versions is not None - # When language="en", filter_text_on_root_and_version selects text_id_2 (English) as root - assert response.text.id == "text_id_2" - assert response.text.language == "en" - # The other texts become versions - assert len(response.versions) == 2 - - -@pytest.mark.asyncio -async def test_get_root_text_by_collection_id_success_with_root_text(): - """Test get_root_text_by_collection_id when root text is found""" - collection_id = str(uuid4()) - language = "bo" - text_id_1 = str(uuid4()) - text_id_2 = str(uuid4()) - group_id_1 = str(uuid4()) - - mock_texts = [ - TextDTO( - id=text_id_1, - title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།", - language="bo", - group_id=group_id_1, - type="version", - is_published=True, - created_date="2025-03-20 09:26:16.571522", - updated_date="2025-03-20 09:26:16.571532", - published_date="2025-03-20 09:26:16.571536", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id=text_id_2, - title="The Way of the Bodhisattva", - language="en", - group_id=group_id_1, - type="version", - is_published=True, - created_date="2025-03-20 09:28:28.076920", - updated_date="2025-03-20 09:28:28.076934", - published_date="2025-03-20 09:28:28.076938", - published_by="pecha", - categories=[], - views=0 - ) - ] - - mock_filtered_result = { - "root_text": mock_texts[0], - "versions": [mock_texts[1]] - } - - with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \ - patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter: - - mock_get_all_texts.return_value = mock_texts - mock_filter.return_value = mock_filtered_result - - result = await get_root_text_by_collection_id(collection_id=collection_id, language=language) - - mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language) - - assert result is not None - assert isinstance(result, RecitationsResponse) - assert len(result.recitations) == 1 - assert isinstance(result.recitations[0], RecitationDTO) - assert str(result.recitations[0].text_id) == text_id_1 - assert result.recitations[0].title == "བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།" - + mock_get_all_texts.return_value = mock_texts + mock_filter.return_value = mock_filtered_result + + result = await get_root_text_by_collection_id(collection_id=collection_id, language=language) + + mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language) + + assert result is not None + assert isinstance(result, RecitationsResponse) + assert len(result.recitations) == 1 + assert isinstance(result.recitations[0], RecitationDTO) + assert str(result.recitations[0].text_id) == text_id_1 + assert result.recitations[0].title == "བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།" + @pytest.mark.asyncio async def test_get_root_text_by_collection_id_no_root_text(): """Test get_root_text_by_collection_id when no root text is found""" @@ -1799,2614 +352,476 @@ async def test_get_root_text_by_collection_id_multiple_groups(): created_date="2025-03-20 09:31:00.000000", updated_date="2025-03-20 09:31:00.000000", published_date="2025-03-20 09:31:00.000000", - published_by="pecha", - categories=[], - views=0 - ) - ] - - # Mock filters for each group - def mock_filter_side_effect(texts, language): - if texts[0].group_id == group_id_1: - return {"root_text": texts[0], "versions": [texts[1]]} - elif texts[0].group_id == group_id_2: - return {"root_text": texts[0], "versions": [texts[1]]} - return {"root_text": None, "versions": texts} - - with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \ - patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter: - - mock_get_all_texts.return_value = mock_texts - mock_filter.side_effect = mock_filter_side_effect - - result = await get_root_text_by_collection_id(collection_id=collection_id, language=language) - - mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language) - - assert result is not None - assert isinstance(result, RecitationsResponse) - assert len(result.recitations) == 2 - assert isinstance(result.recitations[0], RecitationDTO) - assert isinstance(result.recitations[1], RecitationDTO) - # Check that we got both root texts - text_ids = {str(rec.text_id) for rec in result.recitations} - assert text_id_1 in text_ids - assert text_id_3 in text_ids - -@pytest.mark.asyncio -async def test_get_table_of_content_by_type_text_type(): - """Test get_table_of_content_by_type with TEXT type""" - text_id = "text_id_1" - - incoming_toc = TableOfContent( - id="toc_id_1", - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_id_1", - title="section_1", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment(segment_id="pseg_1", segment_number=1) - ], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - - expected_toc = TableOfContent( - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_id_1", - title="section_1", - section_number=1, - segments=[TextSegment(segment_id="seg_id_1", segment_number=1)] - ) - ] - ) - - with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock) as mock_get_segments: - mock_get_segments.return_value = [type("Seg", (), {"id": "seg_id_1", "pecha_segment_id": "pseg_1"})()] - - result = await get_table_of_content_by_type(table_of_content=incoming_toc) - - assert result is not None - assert isinstance(result, TableOfContent) - assert result.text_id == text_id - assert result.type == TableOfContentType.TEXT - assert len(result.sections) == 1 - assert result.sections[0].segments[0].segment_id == "seg_id_1" - -@pytest.mark.asyncio -async def test_get_table_of_content_by_type_sheet_type(): - """Test get_table_of_content_by_type with SHEET type""" - text_id = "text_id_1" - - incoming_toc = TableOfContent( - id="toc_id_1", - text_id=text_id, - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_id_1", - title="section_1", - section_number=1, - parent_id="parent_id_1", - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[], - created_date="2025-03-16 04:40:54.757652", - updated_date="2025-03-16 04:40:54.757652", - published_date="2025-03-16 04:40:54.757652" - ) - ] - ) - - result = await get_table_of_content_by_type(table_of_content=incoming_toc) - - assert result is not None - assert isinstance(result, TableOfContent) - assert result.text_id == text_id - assert result.type == TableOfContentType.SHEET - assert result.sections == incoming_toc.sections - -def test_get_trimmed_segment_dict_next_direction(): - """Test _get_trimmed_segment_dict_ with NEXT direction""" - from pecha_api.texts.texts_service import _get_trimmed_segment_dict_ - - segments_with_position = [ - ("seg_1", 1), - ("seg_2", 2), - ("seg_3", 3), - ("seg_4", 4), - ("seg_5", 5) - ] - - result = _get_trimmed_segment_dict_( - segments_with_position=segments_with_position, - segment_id="seg_2", - direction=PaginationDirection.NEXT, - size=2 - ) - - assert result is not None - assert isinstance(result, dict) - assert len(result) == 2 - assert "seg_2" in result - assert "seg_3" in result - assert result["seg_2"] == 2 - assert result["seg_3"] == 3 - -def test_get_trimmed_segment_dict_previous_direction(): - """Test _get_trimmed_segment_dict_ with PREVIOUS direction""" - from pecha_api.texts.texts_service import _get_trimmed_segment_dict_ - - segments_with_position = [ - ("seg_1", 1), - ("seg_2", 2), - ("seg_3", 3), - ("seg_4", 4), - ("seg_5", 5) - ] - - result = _get_trimmed_segment_dict_( - segments_with_position=segments_with_position, - segment_id="seg_4", - direction=PaginationDirection.PREVIOUS, - size=2 - ) - - assert result is not None - assert isinstance(result, dict) - assert len(result) == 2 - assert "seg_3" in result - assert "seg_4" in result - assert result["seg_3"] == 3 - assert result["seg_4"] == 4 - -def test_get_trimmed_segment_dict_next_at_end(): - """Test _get_trimmed_segment_dict_ with NEXT direction at end of list""" - from pecha_api.texts.texts_service import _get_trimmed_segment_dict_ - - segments_with_position = [ - ("seg_1", 1), - ("seg_2", 2), - ("seg_3", 3) - ] - - result = _get_trimmed_segment_dict_( - segments_with_position=segments_with_position, - segment_id="seg_2", - direction=PaginationDirection.NEXT, - size=5 - ) - - assert result is not None - assert isinstance(result, dict) - assert len(result) == 2 - assert "seg_2" in result - assert "seg_3" in result - -def test_get_trimmed_segment_dict_previous_at_start(): - """Test _get_trimmed_segment_dict_ with PREVIOUS direction at start of list""" - from pecha_api.texts.texts_service import _get_trimmed_segment_dict_ - - segments_with_position = [ - ("seg_1", 1), - ("seg_2", 2), - ("seg_3", 3) - ] - - result = _get_trimmed_segment_dict_( - segments_with_position=segments_with_position, - segment_id="seg_1", - direction=PaginationDirection.PREVIOUS, - size=5 - ) - - assert result is not None - assert isinstance(result, dict) - assert len(result) == 1 - assert "seg_1" in result - -def test_get_segments_with_position_simple(): - """Test _get_segments_with_position_ with simple sections""" - from pecha_api.texts.texts_service import _get_segments_with_position_ - - table_of_content = TableOfContent( - id="toc_id", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2) - ], - sections=[] - ) - ] - ) - - result = _get_segments_with_position_(table_of_content=table_of_content) - - assert result is not None - assert isinstance(result, list) - assert len(result) == 2 - assert result[0] == ("seg_1", 1) - assert result[1] == ("seg_2", 2) - -def test_get_segments_with_position_nested(): - """Test _get_segments_with_position_ with nested sections""" - from pecha_api.texts.texts_service import _get_segments_with_position_ - - table_of_content = TableOfContent( - id="toc_id", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[ - Section( - id="section_2", - title="Section 2", - section_number=2, - segments=[ - TextSegment(segment_id="seg_2", segment_number=1) - ], - sections=[] - ) - ] - ) - ] - ) - - result = _get_segments_with_position_(table_of_content=table_of_content) - - assert result is not None - assert isinstance(result, list) - assert len(result) == 2 - assert result[0] == ("seg_1", 1) - assert result[1] == ("seg_2", 2) - -def test_filter_single_section_with_wanted_segments(): - """Test _filter_single_section_ with wanted segments""" - from pecha_api.texts.texts_service import _filter_single_section_ - - section = Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2), - TextSegment(segment_id="seg_3", segment_number=3) - ], - sections=[] - ) - - wanted_segment_ids = {"seg_1", "seg_3"} - - result = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids) - - assert result is not None - assert isinstance(result, Section) - assert len(result.segments) == 2 - assert result.segments[0].segment_id == "seg_1" - assert result.segments[1].segment_id == "seg_3" - -def test_filter_single_section_no_wanted_segments(): - """Test _filter_single_section_ with no wanted segments""" - from pecha_api.texts.texts_service import _filter_single_section_ - - section = Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2) - ], - sections=[] - ) - - wanted_segment_ids = {"seg_3", "seg_4"} - - result = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids) - - assert result is None - -def test_filter_single_section_nested_with_wanted_segments(): - """Test _filter_single_section_ with nested sections containing wanted segments""" - from pecha_api.texts.texts_service import _filter_single_section_ - - section = Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[ - Section( - id="section_2", - title="Section 2", - section_number=2, - segments=[ - TextSegment(segment_id="seg_2", segment_number=1) - ], - sections=[] - ) - ] - ) - - wanted_segment_ids = {"seg_2"} - - result = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids) - - assert result is not None - assert isinstance(result, Section) - assert len(result.segments) == 0 # Parent has no wanted segments - assert len(result.sections) == 1 # But subsection has wanted segments - assert result.sections[0].segments[0].segment_id == "seg_2" - -def test_generate_paginated_table_of_content_by_segments(): - """Test _generate_paginated_table_of_content_by_segments_""" - from pecha_api.texts.texts_service import _generate_paginated_table_of_content_by_segments_ - - table_of_content = TableOfContent( - id="toc_id", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2), - TextSegment(segment_id="seg_3", segment_number=3) - ], - sections=[] - ) - ] - ) - - segment_dict = { - "seg_1": 1, - "seg_3": 3 - } - - result = _generate_paginated_table_of_content_by_segments_( - table_of_content=table_of_content, - segment_dict=segment_dict - ) - - assert result is not None - assert isinstance(result, TableOfContent) - assert result.type == TableOfContentType.TEXT - assert len(result.sections) == 1 - assert len(result.sections[0].segments) == 2 - assert result.sections[0].segments[0].segment_id == "seg_1" - assert result.sections[0].segments[1].segment_id == "seg_3" - -def test_generate_paginated_table_of_content_by_segments_with_sheet_type(): - """Test _generate_paginated_table_of_content_by_segments_ preserves SHEET type""" - from pecha_api.texts.texts_service import _generate_paginated_table_of_content_by_segments_ - - table_of_content = TableOfContent( - id="toc_id", - text_id="text_id", - type=TableOfContentType.SHEET, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2) - ], - sections=[] - ) - ] - ) - - segment_dict = { - "seg_1": 1 - } - - result = _generate_paginated_table_of_content_by_segments_( - table_of_content=table_of_content, - segment_dict=segment_dict - ) - - assert result is not None - assert isinstance(result, TableOfContent) - assert result.type == TableOfContentType.SHEET - assert len(result.sections) == 1 - assert len(result.sections[0].segments) == 1 - assert result.sections[0].segments[0].segment_id == "seg_1" - -def test_search_section_found(): - """Test _search_section_ when segment is found""" - from pecha_api.texts.texts_service import _search_section_ - - sections = [ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2) - ], - sections=[] - ) - ] - - result = _search_section_(sections=sections, segment_id="seg_2") - - assert result is True - -def test_search_section_not_found(): - """Test _search_section_ when segment is not found""" - from pecha_api.texts.texts_service import _search_section_ - - sections = [ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[] - ) - ] - - result = _search_section_(sections=sections, segment_id="seg_3") - - assert result is False - -def test_search_section_nested(): - """Test _search_section_ with nested sections""" - from pecha_api.texts.texts_service import _search_section_ - - sections = [ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[ - Section( - id="section_2", - title="Section 2", - section_number=2, - segments=[ - TextSegment(segment_id="seg_2", segment_number=1) - ], - sections=[] - ) - ] - ) - ] - - result = _search_section_(sections=sections, segment_id="seg_2") - - assert result is True - -def test_search_table_of_content_where_segment_id_exists_found(): - """Test _search_table_of_content_where_segment_id_exists when segment is found""" - from pecha_api.texts.texts_service import _search_table_of_content_where_segment_id_exists - - table_of_contents = [ - TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[] - ) - ] - ), - TableOfContent( - id="toc_2", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_2", - title="Section 2", - section_number=1, - segments=[ - TextSegment(segment_id="seg_2", segment_number=1) - ], - sections=[] - ) - ] - ) - ] - - result = _search_table_of_content_where_segment_id_exists( - table_of_contents=table_of_contents, - segment_id="seg_2" - ) - - assert result is not None - assert isinstance(result, TableOfContent) - assert result.id == "toc_2" - -def test_search_table_of_content_where_segment_id_exists_not_found(): - """Test _search_table_of_content_where_segment_id_exists when segment is not found""" - from pecha_api.texts.texts_service import _search_table_of_content_where_segment_id_exists - - table_of_contents = [ - TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[] - ) - ] - ) - ] - - with pytest.raises(HTTPException) as exc_info: - _search_table_of_content_where_segment_id_exists( - table_of_contents=table_of_contents, - segment_id="seg_3" - ) - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE - -def test_get_first_segment_and_table_of_content_success(): - """Test _get_first_segment_and_table_of_content_ when segment is found""" - from pecha_api.texts.texts_service import _get_first_segment_and_table_of_content_ - - table_of_contents = [ - TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[] - ) - ] - ) - ] - - segment_id, table_of_content = _get_first_segment_and_table_of_content_( - table_of_contents=table_of_contents - ) - - assert segment_id == "seg_1" - assert table_of_content is not None - assert table_of_content.id == "toc_1" - -def test_get_first_segment_and_table_of_content_no_segments(): - """Test _get_first_segment_and_table_of_content_ when no segments exist""" - from pecha_api.texts.texts_service import _get_first_segment_and_table_of_content_ - - table_of_contents = [ - TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[], - sections=[] - ) - ] - ) - ] - - segment_id, table_of_content = _get_first_segment_and_table_of_content_( - table_of_contents=table_of_contents - ) - - assert segment_id is None - assert table_of_content is None - -@pytest.mark.asyncio -async def test_receive_table_of_content_with_content_and_segment_id(): - """Test _receive_table_of_content with content_id and segment_id""" - from pecha_api.texts.texts_service import _receive_table_of_content - - text_details_request = TextDetailsRequest(content_id="content_1", segment_id="seg_1") - mock_table_of_content = TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[] - ) - ] - ) - - with patch( - "pecha_api.texts.texts_service.get_table_of_content_by_content_id", - new_callable=AsyncMock, - return_value=mock_table_of_content - ) as mock_get_table_of_content_by_content_id, patch( - "pecha_api.texts.texts_service.get_contents_by_id", - new_callable=AsyncMock - ) as mock_get_contents_by_id: - result = await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request) - - assert result == mock_table_of_content - mock_get_table_of_content_by_content_id.assert_awaited_once_with(content_id="content_1") - mock_get_contents_by_id.assert_not_awaited() - -@pytest.mark.asyncio -async def test_receive_table_of_content_with_segment_id_only(): - """Test _receive_table_of_content with only segment_id""" - from pecha_api.texts.texts_service import _receive_table_of_content - - text_details_request = TextDetailsRequest(segment_id="seg_2") - mock_table_of_contents = [ - TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[] - ) - ] - expected_table_of_content = TableOfContent( - id="toc_2", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[] - ) - - with patch( - "pecha_api.texts.texts_service.get_contents_by_id", - new_callable=AsyncMock, - return_value=mock_table_of_contents - ) as mock_get_contents_by_id, patch( - "pecha_api.texts.texts_service._search_table_of_content_where_segment_id_exists", - return_value=expected_table_of_content - ) as mock_search_table_of_content: - result = await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request) - - assert result == expected_table_of_content - mock_get_contents_by_id.assert_awaited_once_with(text_id="text_id") - mock_search_table_of_content.assert_called_once_with( - table_of_contents=mock_table_of_contents, - segment_id="seg_2" - ) - -@pytest.mark.asyncio -async def test_receive_table_of_content_without_segment_and_content_id(): - """Test _receive_table_of_content when no content_id or segment_id is provided""" - from pecha_api.texts.texts_service import _receive_table_of_content - - text_details_request = TextDetailsRequest() - mock_table_of_contents = [ - TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[] - ) - ] - expected_table_of_content = TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[] - ) - - with patch( - "pecha_api.texts.texts_service.get_contents_by_id", - new_callable=AsyncMock, - return_value=mock_table_of_contents - ) as mock_get_contents_by_id, patch( - "pecha_api.texts.texts_service._get_first_segment_and_table_of_content_", - return_value=("seg_1", expected_table_of_content) - ) as mock_get_first_segment_and_table_of_content: - result = await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request) - - assert result == expected_table_of_content - assert text_details_request.segment_id == "seg_1" - mock_get_contents_by_id.assert_awaited_once_with(text_id="text_id") - mock_get_first_segment_and_table_of_content.assert_called_once_with( - table_of_contents=mock_table_of_contents - ) - -@pytest.mark.asyncio -async def test_receive_table_of_content_not_found(): - """Test _receive_table_of_content when table of content is not found""" - from pecha_api.texts.texts_service import _receive_table_of_content - - text_details_request = TextDetailsRequest(content_id="content_1", segment_id="seg_1") - - with patch( - "pecha_api.texts.texts_service.get_table_of_content_by_content_id", - new_callable=AsyncMock, - return_value=None - ): - with pytest.raises(HTTPException) as exc_info: - await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request) - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE - -def test_get_paginated_sections(): - """Test _get_paginated_sections""" - from pecha_api.texts.texts_service import _get_paginated_sections - - sections = [ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1), - TextSegment(segment_id="seg_2", segment_number=2) - ], - sections=[] - ), - Section( - id="section_2", - title="Section 2", - section_number=2, - segments=[ - TextSegment(segment_id="seg_3", segment_number=1) - ], - sections=[] - ), - Section( - id="section_3", - title="Section 3", - section_number=3, - segments=[ - TextSegment(segment_id="seg_4", segment_number=1) - ], - sections=[] - ) - ] - - result = _get_paginated_sections(sections=sections, skip=0, limit=2) - - assert result is not None - assert isinstance(result, list) - assert len(result) == 2 - assert result[0].id == "section_1" - assert result[1].id == "section_2" - # Each section should only have first segment - assert len(result[0].segments) == 1 - assert result[0].segments[0].segment_id == "seg_1" - -def test_get_paginated_sections_with_skip(): - """Test _get_paginated_sections with skip""" - from pecha_api.texts.texts_service import _get_paginated_sections - - sections = [ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="seg_1", segment_number=1) - ], - sections=[] - ), - Section( - id="section_2", - title="Section 2", - section_number=2, - segments=[ - TextSegment(segment_id="seg_2", segment_number=1) - ], - sections=[] - ) - ] - - result = _get_paginated_sections(sections=sections, skip=1, limit=2) - - assert result is not None - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].id == "section_2" - -@pytest.mark.asyncio -async def test_update_text_details_cache_update_fails(): - """Test update_text_details when cache update fails""" - mock_text_details = TextDTO( - id="text_id_1", - title="text_title", - language="bo", - group_id="group_id_1", - type="version", - is_published=False, - created_date="created_date", - updated_date="updated_date", - published_date="published_date", - published_by="published_by", - categories=[], - views=0 - ) - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_details), \ - patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details), \ - patch("pecha_api.texts.texts_service.update_text_details_cache", new_callable=AsyncMock, side_effect=Exception("Cache error")), \ - patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock, return_value=None) as mock_invalidate: - - response = await update_text_details(text_id="text_id_1", update_text_request=UpdateTextRequest(title="updated_title", is_published=True)) - - assert response is not None - # Verify that invalidate was called as fallback - mock_invalidate.assert_called_once_with(text_id="text_id_1") - -@pytest.mark.asyncio -async def test_get_text_by_text_id_or_collection_with_cache(): - """Test get_text_by_text_id_or_collection fetches text (cache is currently disabled in code)""" - text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64" - mock_text = TextDTO( - id=text_id, - title="Test Text", - language="bo", - group_id="group_id_1", - type="commentary", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - - # Cache is commented out in the actual code, so we need to mock the actual data fetch - with patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text), \ - patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock): - response = await get_text_by_text_id_or_collection(text_id=text_id, collection_id=None) - - assert response is not None - assert isinstance(response, TextDTO) - assert response.id == text_id - assert response.title == "Test Text" - - -@pytest.mark.asyncio -async def test_update_text_details_with_cache_invalidation(): - """Test update_text_details updates text successfully""" - text_id = "123e4567-e89b-12d3-a456-426614174000" - update_request = UpdateTextRequest( - title="Updated Title", - language="bo" - ) - - updated_text = TextDTO( - id=text_id, - title="Updated Title", - language="bo", - group_id="group_id_1", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=updated_text), \ - patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=updated_text) as mock_update, \ - patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock): - - result = await update_text_details(text_id=text_id, update_text_request=update_request) - - assert result is not None - assert result.title == "Updated Title" - mock_update.assert_called_once_with(text_id=text_id, update_text_request=update_request) - - -@pytest.mark.asyncio -async def test_remove_table_of_content_by_text_id_success(): - """Test remove_table_of_content_by_text_id successfully deletes content""" - text_id = "123e4567-e89b-12d3-a456-426614174000" - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.delete_table_of_content_by_text_id", new_callable=AsyncMock) as mock_delete: - await remove_table_of_content_by_text_id(text_id=text_id) - - mock_delete.assert_called_once_with(text_id=text_id) - - -@pytest.mark.asyncio -async def test_get_text_by_collection_id_empty_result(): - """Test get_text_by_text_id_or_collection with collection_id returns empty when no texts""" - collection_id = "collection_id_1" - language = "bo" - - mock_collection = CollectionModel( - id=collection_id, - title="Empty Collection", - description="No texts", - language=language, - slug="empty-collection", - has_child=False - ) - - with patch("pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=None), \ - patch("pecha_api.texts.texts_service.get_collection", new_callable=AsyncMock, return_value=mock_collection), \ - patch("pecha_api.texts.texts_service._get_texts_by_collection_id", new_callable=AsyncMock, return_value=([], 0)), \ - patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=[]), \ - patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock): - - response = await get_text_by_text_id_or_collection( - text_id=None, - collection_id=collection_id, - language=language, - skip=0, - limit=10 - ) - - assert response is not None - assert isinstance(response, TextsCategoryResponse) - assert response.total == 0 - - -# Tests for new private functions - -def test_group_texts_by_group_id_with_language_sorting(): - """Test _group_texts_by_group_id groups texts and sorts by language preference""" - from pecha_api.texts.texts_service import _group_texts_by_group_id - - # Create mock texts with different group_ids and languages - mock_texts = [ - TextDTO( - id="text_id_1", - title="Text 1 Bo", - language="bo", - group_id="group_1", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_2", - title="Text 2 En", - language="en", - group_id="group_1", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_3", - title="Text 3 Zh", - language="zh", - group_id="group_1", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_4", - title="Text 4 Bo Group2", - language="bo", - group_id="group_2", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - ] - - # Test with 'en' as preferred language - result = _group_texts_by_group_id(texts=mock_texts, language="en") - - # Verify grouping - assert len(result) == 2 - assert "group_1" in result - assert "group_2" in result - assert len(result["group_1"]) == 3 - assert len(result["group_2"]) == 1 - - # Verify sorting - 'en' should be first for group_1 - assert result["group_1"][0].language == "en" - assert result["group_1"][1].language == "bo" - assert result["group_1"][2].language == "zh" - - -def test_group_texts_by_group_id_with_bo_preference(): - """Test _group_texts_by_group_id with Tibetan (bo) language preference""" - from pecha_api.texts.texts_service import _group_texts_by_group_id - - mock_texts = [ - TextDTO( - id="text_id_1", - title="Text En", - language="en", - group_id="group_1", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text_id_2", - title="Text Bo", - language="bo", - group_id="group_1", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - ] - - result = _group_texts_by_group_id(texts=mock_texts, language="bo") - - # Verify 'bo' is first - assert result["group_1"][0].language == "bo" - assert result["group_1"][1].language == "en" - - -def test_get_language_priority_via_textutils(): - """Test language priority through TextUtils (used by service layer)""" - from pecha_api.texts.texts_utils import TextUtils - - # Test with 'bo' preference - assert TextUtils.get_language_priority("bo", "bo") == 0 - assert TextUtils.get_language_priority("en", "bo") == 1 - assert TextUtils.get_language_priority("zh", "bo") == 2 - assert TextUtils.get_language_priority("unknown", "bo") == 999 - - # Test with 'en' preference - assert TextUtils.get_language_priority("en", "en") == 0 - assert TextUtils.get_language_priority("bo", "en") == 1 - assert TextUtils.get_language_priority("zh", "en") == 2 - assert TextUtils.get_language_priority("unknown", "en") == 999 - - # Test with 'zh' preference - assert TextUtils.get_language_priority("zh", "zh") == 0 - assert TextUtils.get_language_priority("en", "zh") == 1 - assert TextUtils.get_language_priority("bo", "zh") == 2 - - -def test_get_language_priority_with_none_via_textutils(): - """Test TextUtils.get_language_priority handles None text_language""" - from pecha_api.texts.texts_utils import TextUtils - - # None language should get default priority - assert TextUtils.get_language_priority(None, "bo") == 999 - assert TextUtils.get_language_priority(None, "en") == 999 - - -def test_group_texts_by_group_id_empty_texts(): - """Test _group_texts_by_group_id with empty text list""" - from pecha_api.texts.texts_service import _group_texts_by_group_id - - result = _group_texts_by_group_id(texts=[], language="bo") - - assert result == {} - - -def test_group_texts_by_group_id_single_group(): - """Test _group_texts_by_group_id with single group""" - from pecha_api.texts.texts_service import _group_texts_by_group_id - - mock_texts = [ - TextDTO( - id="text_id_1", - title="Text 1", - language="bo", - group_id="single_group", - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - ] - - result = _group_texts_by_group_id(texts=mock_texts, language="bo") - - assert len(result) == 1 - assert "single_group" in result - assert len(result["single_group"]) == 1 - assert result["single_group"][0].id == "text_id_1" - - -def test_language_orders_constant(): - """Test LANGUAGE_ORDERS constant is properly defined""" - # Verify the constant exists and has expected structure - assert LANGUAGE_ORDERS is not None - assert isinstance(LANGUAGE_ORDERS, dict) - - # Check all three languages are defined - assert 'bo' in LANGUAGE_ORDERS - assert 'en' in LANGUAGE_ORDERS - assert 'zh' in LANGUAGE_ORDERS - - # Verify 'bo' preference order - assert LANGUAGE_ORDERS['bo']['bo'] == 0 - assert LANGUAGE_ORDERS['bo']['en'] == 1 - assert LANGUAGE_ORDERS['bo']['zh'] == 2 - - # Verify 'en' preference order - assert LANGUAGE_ORDERS['en']['en'] == 0 - assert LANGUAGE_ORDERS['en']['bo'] == 1 - assert LANGUAGE_ORDERS['en']['zh'] == 2 - - # Verify 'zh' preference order - assert LANGUAGE_ORDERS['zh']['zh'] == 0 - assert LANGUAGE_ORDERS['zh']['en'] == 1 - assert LANGUAGE_ORDERS['zh']['bo'] == 2 - - -@pytest.mark.asyncio -async def test_get_commentaries_by_text_id_success(): - """Test get_commentaries_by_text_id returns matching commentaries""" - from uuid import UUID - - text_id = "text_id_1" - group_id = "group_id_1" - - mock_root_text = TextDTO( - id=text_id, - title="Root Text", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - - mock_commentaries = [ - TextDTO( - id="commentary_id_1", - title="Commentary 1", - language="bo", - group_id="commentary_group_1", - type="commentary", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[group_id], # Commentary references the root text's group_id - views=0 - ), - TextDTO( - id="commentary_id_2", - title="Commentary 2", - language="bo", - group_id="commentary_group_2", - type="commentary", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=["other_group"], # This one shouldn't match - views=0 - ) - ] - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_root_text), \ - patch("pecha_api.texts.texts_service.TextUtils.get_commentaries_by_text_type", new_callable=AsyncMock, return_value=mock_commentaries): - - result = await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10) - - assert result is not None - assert len(result) == 1 - assert result[0].id == "commentary_id_1" - assert group_id in result[0].categories - - -@pytest.mark.asyncio -async def test_get_commentaries_by_text_id_no_matching_commentaries(): - """Test get_commentaries_by_text_id with no matching commentaries""" - text_id = "text_id_1" - group_id = "group_id_1" - - mock_root_text = TextDTO( - id=text_id, - title="Root Text", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - - mock_commentaries = [ - TextDTO( - id="commentary_id_1", - title="Commentary 1", - language="bo", - group_id="commentary_group_1", - type="commentary", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=["other_group"], # Different group - views=0 - ) - ] - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_root_text), \ - patch("pecha_api.texts.texts_service.TextUtils.get_commentaries_by_text_type", new_callable=AsyncMock, return_value=mock_commentaries): - - result = await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10) - - assert result is not None - assert len(result) == 0 - - -@pytest.mark.asyncio -async def test_get_commentaries_by_text_id_invalid_text(): - """Test get_commentaries_by_text_id raises exception for invalid text""" - text_id = "invalid_text_id" - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, side_effect=HTTPException(status_code=404, detail="Text not found")): - with pytest.raises(HTTPException) as exc_info: - await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10) - - assert exc_info.value.status_code == 404 - - -@pytest.mark.asyncio -async def test_replace_pecha_segment_id_with_segment_id_success(): - """Test replace_pecha_segment_id_with_segment_id converts pecha segment IDs to database segment IDs""" - text_id = "text_id_1" - - # Mock segments from database - class MockSegment: - def __init__(self, segment_id, pecha_segment_id): - self.id = segment_id - self.pecha_segment_id = pecha_segment_id - - mock_segments = [ - MockSegment("db_seg_1", "pecha_seg_1"), - MockSegment("db_seg_2", "pecha_seg_2") - ] - - incoming_toc = TableOfContent( - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="pecha_seg_1", segment_number=1), - TextSegment(segment_id="pecha_seg_2", segment_number=2) - ] - ) - ] - ) - - with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments): - result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc) - - assert result is not None - assert isinstance(result, TableOfContent) - assert result.text_id == text_id - assert result.type == TableOfContentType.TEXT - assert len(result.sections) == 1 - assert len(result.sections[0].segments) == 2 - # Verify segment IDs were replaced - assert result.sections[0].segments[0].segment_id == "db_seg_1" - assert result.sections[0].segments[1].segment_id == "db_seg_2" - - -@pytest.mark.asyncio -async def test_replace_pecha_segment_id_with_segment_id_multiple_sections(): - """Test replace_pecha_segment_id_with_segment_id with multiple sections""" - text_id = "text_id_1" - - class MockSegment: - def __init__(self, segment_id, pecha_segment_id): - self.id = segment_id - self.pecha_segment_id = pecha_segment_id - - mock_segments = [ - MockSegment("db_seg_1", "pecha_seg_1"), - MockSegment("db_seg_2", "pecha_seg_2"), - MockSegment("db_seg_3", "pecha_seg_3") - ] - - incoming_toc = TableOfContent( - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[ - TextSegment(segment_id="pecha_seg_1", segment_number=1) - ] - ), - Section( - id="section_2", - title="Section 2", - section_number=2, - segments=[ - TextSegment(segment_id="pecha_seg_2", segment_number=1), - TextSegment(segment_id="pecha_seg_3", segment_number=2) - ] - ) - ] - ) - - with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments): - result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc) - - assert result is not None - assert len(result.sections) == 2 - assert result.sections[0].segments[0].segment_id == "db_seg_1" - assert result.sections[1].segments[0].segment_id == "db_seg_2" - assert result.sections[1].segments[1].segment_id == "db_seg_3" - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_service_success(): - """Test get_text_by_pecha_text_ids_service with valid pecha_text_ids""" - class MockText: - def __init__(self, text_id, pecha_text_id, title, language, group_id, type_val, is_published, - created_date, updated_date, published_date, published_by, categories=None, - views=0, source_link=None, ranking=None, license_val=None): - self.id = text_id - self.pecha_text_id = pecha_text_id - self.title = title - self.language = language - self.group_id = group_id - self.type = type_val - self.is_published = is_published - self.created_date = created_date - self.updated_date = updated_date - self.published_date = published_date - self.published_by = published_by - self.categories = categories or [] - self.views = views - self.source_link = source_link - self.ranking = ranking - self.license = license_val - - mock_texts = [ - MockText( - text_id="text_id_1", - pecha_text_id="pecha_text_id_1", - title="Test Text 1", - language="bo", - group_id="group_id_1", - type_val="root_text", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="user_1", - categories=["cat1"], - views=10, - source_link="https://source1.com", - ranking=1, - license_val="CC0" - ), - MockText( - text_id="text_id_2", - pecha_text_id="pecha_text_id_2", - title="Test Text 2", - language="en", - group_id="group_id_2", - type_val="version", - is_published=True, - created_date="2025-01-02T00:00:00", - updated_date="2025-01-02T00:00:00", - published_date="2025-01-02T00:00:00", - published_by="user_2", - categories=["cat2"], - views=20, - source_link="https://source2.com", - ranking=2, - license_val="CC BY" - ) - ] - - request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_text_id_1", "pecha_text_id_2"]) - - with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts): - result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request) - - assert result is not None - assert len(result) == 2 - assert isinstance(result[0], TextDTO) - assert result[0].id == "text_id_1" - assert result[0].pecha_text_id == "pecha_text_id_1" - assert result[0].title == "Test Text 1" - assert result[0].language == "bo" - assert result[0].group_id == "group_id_1" - assert result[0].type == "root_text" - assert result[0].is_published == True - assert result[0].categories == ["cat1"] - assert result[0].views == 10 - assert result[0].source_link == "https://source1.com" - assert result[0].ranking == 1 - assert result[0].license == "CC0" - - assert isinstance(result[1], TextDTO) - assert result[1].id == "text_id_2" - assert result[1].pecha_text_id == "pecha_text_id_2" - assert result[1].title == "Test Text 2" - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_service_empty_result(): - """Test get_text_by_pecha_text_ids_service when no texts are found""" - request = TextsByPechaTextIdsRequest(pecha_text_ids=["nonexistent_pecha_id"]) - - with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=[]): - result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request) - - assert result is not None - assert len(result) == 0 - assert result == [] - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_service_single_text(): - """Test get_text_by_pecha_text_ids_service with single pecha_text_id""" - class MockText: - def __init__(self): - self.id = "text_id_single" - self.pecha_text_id = "pecha_text_id_single" - self.title = "Single Test Text" - self.language = "bo" - self.group_id = "group_id_single" - self.type = "root_text" - self.is_published = True - self.created_date = "2025-01-01T00:00:00" - self.updated_date = "2025-01-01T00:00:00" - self.published_date = "2025-01-01T00:00:00" - self.published_by = "user_single" - self.categories = [] - self.views = 5 - self.source_link = "https://source.com" - self.ranking = 1 - self.license = "CC0" - - mock_texts = [MockText()] - request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_text_id_single"]) - - with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts): - result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request) - - assert result is not None - assert len(result) == 1 - assert isinstance(result[0], TextDTO) - assert result[0].id == "text_id_single" - assert result[0].pecha_text_id == "pecha_text_id_single" - assert result[0].title == "Single Test Text" - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_service_with_none_optional_fields(): - """Test get_text_by_pecha_text_ids_service with texts having None optional fields""" - class MockText: - def __init__(self): - self.id = "text_id_minimal" - self.pecha_text_id = None - self.title = "Minimal Text" - self.language = "en" - self.group_id = "group_id_minimal" - self.type = "version" - self.is_published = False - self.created_date = "2025-01-01T00:00:00" - self.updated_date = "2025-01-01T00:00:00" - self.published_date = "2025-01-01T00:00:00" - self.published_by = "user_minimal" - self.categories = None - self.views = 0 - self.source_link = None - self.ranking = None - self.license = None - - mock_texts = [MockText()] - request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_text_id_minimal"]) - - with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts): - result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request) - - assert result is not None - assert len(result) == 1 - assert isinstance(result[0], TextDTO) - assert result[0].id == "text_id_minimal" - # Note: str(None) returns "None" as a string in the service - assert result[0].pecha_text_id == "None" - assert result[0].title == "Minimal Text" - assert result[0].categories is None - assert result[0].views == 0 - assert result[0].source_link is None - assert result[0].ranking is None - assert result[0].license is None - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_service_empty_request(): - """Test get_text_by_pecha_text_ids_service with empty pecha_text_ids list""" - request = TextsByPechaTextIdsRequest(pecha_text_ids=[]) - - with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=[]): - result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request) - - assert result is not None - assert len(result) == 0 - assert result == [] - - -@pytest.mark.asyncio -async def test_get_text_by_pecha_text_ids_service_multiple_texts_various_types(): - """Test get_text_by_pecha_text_ids_service with texts of various types""" - class MockText: - def __init__(self, text_id, pecha_text_id, title, text_type): - self.id = text_id - self.pecha_text_id = pecha_text_id - self.title = title - self.language = "bo" - self.group_id = "group_id_1" - self.type = text_type - self.is_published = True - self.created_date = "2025-01-01T00:00:00" - self.updated_date = "2025-01-01T00:00:00" - self.published_date = "2025-01-01T00:00:00" - self.published_by = "user_1" - self.categories = [] - self.views = 10 - self.source_link = "https://source.com" - self.ranking = 1 - self.license = "CC0" - - mock_texts = [ - MockText("text_id_1", "pecha_1", "Root Text", "root_text"), - MockText("text_id_2", "pecha_2", "Version Text", "version"), - MockText("text_id_3", "pecha_3", "Commentary Text", "commentary") - ] - - request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_1", "pecha_2", "pecha_3"]) - - with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts): - result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request) - - assert result is not None - assert len(result) == 3 - assert result[0].type == "root_text" - assert result[1].type == "version" - assert result[2].type == "commentary" - assert result[0].title == "Root Text" - assert result[1].title == "Version Text" - assert result[2].title == "Commentary Text" - - -@pytest.mark.asyncio -async def test_get_titles_and_ids_by_query_success(): - """Test get_titles_and_ids_by_query returns title search results""" - mock_response_data = [ - {"title": {"en": "A Title"}, "language": "en"}, - {"title": {"bo": "མཚན"}, "language": "bo"}, - {"title": {"en": "A Title"}, "language": "en"} - ] - - mock_http_response = Mock() - mock_http_response.json.return_value = mock_response_data - mock_http_response.raise_for_status = Mock() - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_http_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - class MockText: - def __init__(self, text_id: str, title: str): - self.id = text_id - self.title = title - - mock_texts = [ - MockText(text_id="id_1", title="A Title"), - MockText(text_id="id_2", title="མཚན") - ] - - with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \ - patch("httpx.AsyncClient", return_value=mock_client), \ - patch("pecha_api.texts.texts_service.get_texts_by_titles", new_callable=AsyncMock, return_value=mock_texts) as mock_get_texts: - result = await get_titles_and_ids_by_query( - title="Test", - author=None, - limit=20, - offset=0 - ) - - assert len(result) == 2 - assert result[0].id == "id_1" - assert result[0].title == "A Title" - assert result[1].id == "id_2" - assert result[1].title == "མཚན" - - call_args = mock_client.get.call_args - assert call_args.kwargs["params"]["title"] == "Test" - assert call_args.kwargs["params"]["limit"] == 20 - assert call_args.kwargs["params"]["offset"] == 0 - - mock_get_texts.assert_awaited_once() - called_titles = mock_get_texts.call_args.kwargs["titles"] - assert "A Title" in called_titles - assert "མཚན" in called_titles - - -def test_extract_title_for_language_variants(): - """Test extract_title_for_language with dict, string, and other payloads""" - from pecha_api.texts.texts_service import extract_title_for_language - - assert extract_title_for_language({"bo": "མཚན", "en": "Title"}, "bo") == "མཚན" - assert extract_title_for_language({"en": " ", "zh": "Title"}, "fr") == "Title" - assert extract_title_for_language(" A Title ", "bo") == "A Title" - assert extract_title_for_language(12345, "bo") is None - - -@pytest.mark.asyncio -async def test_get_text_by_text_id_or_collection_cache_hit(): - """Test get_text_by_text_id_or_collection returns cached data""" - text_id = "text_id_1" - cached_text = TextDTO( - id=text_id, - title="Cached Title", - language="bo", - group_id="group_id_1", - type="root_text", - is_published=True, - created_date="2025-03-21 09:40:34.025024", - updated_date="2025-03-21 09:40:34.025035", - published_date="2025-03-21 09:40:34.025038", - published_by="pecha", - categories=[], - views=0 - ) - - with patch("pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=cached_text) as mock_get_cache, \ - patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock) as mock_set_cache, \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock) as mock_get_detail: - result = await get_text_by_text_id_or_collection(text_id=text_id, collection_id=None) - - assert result == cached_text - mock_get_cache.assert_awaited_once() - mock_set_cache.assert_not_awaited() - mock_get_detail.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_get_titles_and_ids_by_query_requires_title_or_author(): - """Test get_titles_and_ids_by_query requires title or author""" - with pytest.raises(HTTPException) as exc_info: - await get_titles_and_ids_by_query(title=None, author=None, limit=10, offset=0) - - assert exc_info.value.status_code == 400 - - -@pytest.mark.asyncio -async def test_get_titles_and_ids_by_query_missing_external_url(): - """Test get_titles_and_ids_by_query raises when API URL is missing""" - with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", None): - with pytest.raises(HTTPException) as exc_info: - await get_titles_and_ids_by_query(title="Test", author=None, limit=10, offset=0) - - assert exc_info.value.status_code == 500 - - -@pytest.mark.asyncio -async def test_get_titles_and_ids_by_query_author_param_and_empty_titles(): - """Test get_titles_and_ids_by_query with author-only and no valid titles""" - mock_response_data = [ - {"title": {"en": " "}}, - "not-a-dict" - ] - - mock_http_response = Mock() - mock_http_response.json.return_value = mock_response_data - mock_http_response.raise_for_status = Mock() - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_http_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \ - patch("httpx.AsyncClient", return_value=mock_client), \ - patch("pecha_api.texts.texts_service.get_texts_by_titles", new_callable=AsyncMock) as mock_get_texts: - result = await get_titles_and_ids_by_query( - title=None, - author="Author", - limit=20, - offset=0 - ) - - assert result == [] - call_args = mock_client.get.call_args - assert call_args.kwargs["params"]["author"] == "Author" - assert "title" not in call_args.kwargs["params"] - mock_get_texts.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_get_titles_and_ids_by_query_http_status_error(): - """Test get_titles_and_ids_by_query handles HTTPStatusError""" - mock_request = httpx.Request("GET", "https://external.example/v2/texts") - mock_response = httpx.Response(500, request=mock_request) - - mock_http_response = Mock() - mock_http_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Server error", - request=mock_request, - response=mock_response - ) - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_http_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \ - patch("httpx.AsyncClient", return_value=mock_client), \ - patch("pecha_api.texts.texts_service.handle_http_status_error", side_effect=HTTPException(status_code=502, detail="Bad Gateway")) as mock_handle: - with pytest.raises(HTTPException) as exc_info: - await get_titles_and_ids_by_query(title="Test", author=None, limit=10, offset=0) - - assert exc_info.value.status_code == 502 - mock_handle.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_titles_and_ids_by_query_request_error(): - """Test get_titles_and_ids_by_query handles RequestError""" - mock_request = httpx.Request("GET", "https://external.example/v2/texts") - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=httpx.RequestError("Network error", request=mock_request)) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \ - patch("httpx.AsyncClient", return_value=mock_client), \ - patch("pecha_api.texts.texts_service.handle_request_error", side_effect=HTTPException(status_code=503, detail="Service unavailable")) as mock_handle: - with pytest.raises(HTTPException) as exc_info: - await get_titles_and_ids_by_query(title="Test", author=None, limit=10, offset=0) - - assert exc_info.value.status_code == 503 - mock_handle.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_texts_by_collection_id_skip_duplicate_and_limit(): - """Test _get_texts_by_collection_id skip, duplicate, and limit handling""" - from pecha_api.texts.texts_service import _get_texts_by_collection_id - - class MockText: - def __init__(self, text_id: str, group_id: str, language: str): - self.id = text_id - self.pecha_text_id = f"pecha_{text_id}" - self.title = f"title_{text_id}" - self.language = language - self.group_id = group_id - self.type = "root_text" - self.is_published = True - self.created_date = "2025-01-01T00:00:00" - self.updated_date = "2025-01-01T00:00:00" - self.published_date = "2025-01-01T00:00:00" - self.published_by = "user_1" - - texts = [ - MockText("1", "g1", "bo"), - MockText("2", "g1", "bo"), - MockText("3", "g2", "bo") - ] - - with patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=texts), \ - patch("pecha_api.texts.texts_service.TextUtils.get_language_priority", return_value=0): - result, total_unique_group_ids = await _get_texts_by_collection_id( - collection_id="collection_1", - language="bo", - skip=0, - limit=10 - ) - - assert total_unique_group_ids == 2 - assert len(result) == 2 - assert result[0].group_id == "g1" - assert result[1].group_id == "g2" - - -@pytest.mark.asyncio -async def test_get_texts_by_collection_id_skip_first_group(): - """Test _get_texts_by_collection_id skip branch""" - from pecha_api.texts.texts_service import _get_texts_by_collection_id - - class MockText: - def __init__(self, text_id: str, group_id: str): - self.id = text_id - self.pecha_text_id = f"pecha_{text_id}" - self.title = f"title_{text_id}" - self.language = "bo" - self.group_id = group_id - self.type = "root_text" - self.is_published = True - self.created_date = "2025-01-01T00:00:00" - self.updated_date = "2025-01-01T00:00:00" - self.published_date = "2025-01-01T00:00:00" - self.published_by = "user_1" - - texts = [ - MockText("1", "g1"), - MockText("2", "g2") - ] - - with patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=texts), \ - patch("pecha_api.texts.texts_service.TextUtils.get_language_priority", return_value=0): - result, _ = await _get_texts_by_collection_id( - collection_id="collection_1", - language="bo", - skip=1, - limit=10 - ) - - assert len(result) == 1 - assert result[0].group_id == "g2" - - -@pytest.mark.asyncio -async def test_get_texts_by_collection_id_breaks_on_limit(): - """Test _get_texts_by_collection_id stops at limit""" - from pecha_api.texts.texts_service import _get_texts_by_collection_id - - class MockText: - def __init__(self, text_id: str, group_id: str): - self.id = text_id - self.pecha_text_id = f"pecha_{text_id}" - self.title = f"title_{text_id}" - self.language = "bo" - self.group_id = group_id - self.type = "root_text" - self.is_published = True - self.created_date = "2025-01-01T00:00:00" - self.updated_date = "2025-01-01T00:00:00" - self.published_date = "2025-01-01T00:00:00" - self.published_by = "user_1" - - texts = [ - MockText("1", "g1"), - MockText("2", "g2") - ] - - with patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=texts), \ - patch("pecha_api.texts.texts_service.TextUtils.get_language_priority", return_value=0): - result, _ = await _get_texts_by_collection_id( - collection_id="collection_1", - language="bo", - skip=0, - limit=1 - ) - - assert len(result) == 1 - assert result[0].group_id == "g1" - - -def test_get_first_segment_and_table_of_content_nested_sections(): - """Test _get_first_segment_and_table_of_content_ finds nested segment""" - from pecha_api.texts.texts_service import _get_first_segment_and_table_of_content_ - - table_of_contents = [ - TableOfContent( - id="toc_1", - text_id="text_id", - type=TableOfContentType.TEXT, - sections=[ - Section( - id="section_1", - title="Section 1", - section_number=1, - segments=[], - sections=[ - Section( - id="section_2", - title="Section 2", - section_number=2, - segments=[TextSegment(segment_id="seg_2", segment_number=1)], - sections=[] - ) - ] - ) - ] - ) - ] - - segment_id, table_of_content = _get_first_segment_and_table_of_content_( - table_of_contents=table_of_contents - ) - - assert segment_id == "seg_2" - assert table_of_content is not None - - -@pytest.mark.asyncio -async def test_get_commentaries_by_text_id_returns_not_found_when_false(): - """Test get_commentaries_by_text_id raises 404 when text is invalid""" - text_id = "missing_text_id" - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10) - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - - -# ============================================================================ -# get_text_languages Service Tests -# ============================================================================ - -@pytest.mark.asyncio -async def test_get_text_languages_success(): - """Test get_text_languages returns available languages with version counts""" - text_id = "123e4567-e89b-12d3-a456-426614174000" - group_id = "group_id_1" - - mock_text_detail = TextDTO( - id=text_id, - title="Test Text", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - categories=[], - views=0 - ) - - mock_texts = [ - MagicMock(language="bo"), - MagicMock(language="bo"), - MagicMock(language="en"), - MagicMock(language="zh") + published_by="pecha", + categories=[], + views=0 + ) ] - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts): + # Mock filters for each group + def mock_filter_side_effect(texts, language): + if texts[0].group_id == group_id_1: + return {"root_text": texts[0], "versions": [texts[1]]} + elif texts[0].group_id == group_id_2: + return {"root_text": texts[0], "versions": [texts[1]]} + return {"root_text": None, "versions": texts} + + with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \ + patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter: + + mock_get_all_texts.return_value = mock_texts + mock_filter.side_effect = mock_filter_side_effect - response = await get_text_languages(text_id=text_id) + result = await get_root_text_by_collection_id(collection_id=collection_id, language=language) - assert response is not None - assert isinstance(response, LanguageResponse) - assert response.text_id == text_id - assert response.title == "Test Text" - assert len(response.available_languages) == 3 + mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language) - lang_dict = {lang.language: lang.version_count for lang in response.available_languages} - assert lang_dict["bo"] == 2 - assert lang_dict["en"] == 1 - assert lang_dict["zh"] == 1 - + assert result is not None + assert isinstance(result, RecitationsResponse) + assert len(result.recitations) == 2 + assert isinstance(result.recitations[0], RecitationDTO) + assert isinstance(result.recitations[1], RecitationDTO) + # Check that we got both root texts + text_ids = {str(rec.text_id) for rec in result.recitations} + assert text_id_1 in text_ids + assert text_id_3 in text_ids @pytest.mark.asyncio -async def test_get_text_languages_text_not_found(): - """Test get_text_languages raises 404 when text doesn't exist""" - text_id = "non-existent-id" +async def test_get_table_of_content_by_type_text_type(): + """Test get_table_of_content_by_type with TEXT type""" + text_id = "text_id_1" - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_text_languages(text_id=text_id) + incoming_toc = TableOfContent( + id="toc_id_1", + text_id=text_id, + type=TableOfContentType.TEXT, + sections=[ + Section( + id="section_id_1", + title="section_1", + section_number=1, + parent_id="parent_id_1", + segments=[ + TextSegment(segment_id="pseg_1", segment_number=1) + ], + sections=[], + created_date="2025-03-16 04:40:54.757652", + updated_date="2025-03-16 04:40:54.757652", + published_date="2025-03-16 04:40:54.757652" + ) + ] + ) + + expected_toc = TableOfContent( + text_id=text_id, + type=TableOfContentType.TEXT, + sections=[ + Section( + id="section_id_1", + title="section_1", + section_number=1, + segments=[TextSegment(segment_id="seg_id_1", segment_number=1)] + ) + ] + ) + + with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock) as mock_get_segments: + mock_get_segments.return_value = [type("Seg", (), {"id": "seg_id_1", "pecha_segment_id": "pseg_1"})()] - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - + result = await get_table_of_content_by_type(table_of_content=incoming_toc) + + assert result is not None + assert isinstance(result, TableOfContent) + assert result.text_id == text_id + assert result.type == TableOfContentType.TEXT + assert len(result.sections) == 1 + assert result.sections[0].segments[0].segment_id == "seg_id_1" @pytest.mark.asyncio -async def test_get_text_languages_empty_languages(): - """Test get_text_languages returns empty list when no texts have languages""" - text_id = "123e4567-e89b-12d3-a456-426614174000" - group_id = "group_id_1" +async def test_get_table_of_content_by_type_sheet_type(): + """Test get_table_of_content_by_type with SHEET type""" + text_id = "text_id_1" - mock_text_detail = TextDTO( - id=text_id, - title="Test Text", - language=None, - group_id=group_id, - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - categories=[], - views=0 + incoming_toc = TableOfContent( + id="toc_id_1", + text_id=text_id, + type=TableOfContentType.SHEET, + sections=[ + Section( + id="section_id_1", + title="section_1", + section_number=1, + parent_id="parent_id_1", + segments=[ + TextSegment(segment_id="seg_1", segment_number=1) + ], + sections=[], + created_date="2025-03-16 04:40:54.757652", + updated_date="2025-03-16 04:40:54.757652", + published_date="2025-03-16 04:40:54.757652" + ) + ] ) - mock_texts = [ - MagicMock(language=None), - MagicMock(language=None) - ] + result = await get_table_of_content_by_type(table_of_content=incoming_toc) - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts): - - response = await get_text_languages(text_id=text_id) - - assert response is not None - assert isinstance(response, LanguageResponse) - assert response.available_languages == [] + assert result is not None + assert isinstance(result, TableOfContent) + assert result.text_id == text_id + assert result.type == TableOfContentType.SHEET + assert result.sections == incoming_toc.sections + @pytest.mark.asyncio -async def test_get_text_languages_single_language(): - """Test get_text_languages with single language multiple versions""" - text_id = "123e4567-e89b-12d3-a456-426614174000" - group_id = "group_id_1" - - mock_text_detail = TextDTO( - id=text_id, - title="Tibetan Only Text", +async def test_update_text_details_cache_update_fails(): + """Test update_text_details when cache update fails""" + mock_text_details = TextDTO( + id="text_id_1", + title="text_title", language="bo", - group_id=group_id, + group_id="group_id_1", type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", + is_published=False, + created_date="created_date", + updated_date="updated_date", + published_date="published_date", + published_by="published_by", categories=[], views=0 ) - mock_texts = [ - MagicMock(language="bo"), - MagicMock(language="bo"), - MagicMock(language="bo"), - MagicMock(language="bo"), - MagicMock(language="bo") - ] - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts): + patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_details), \ + patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details), \ + patch("pecha_api.texts.texts_service.update_text_details_cache", new_callable=AsyncMock, side_effect=Exception("Cache error")), \ + patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock, return_value=None) as mock_invalidate: - response = await get_text_languages(text_id=text_id) + response = await update_text_details(text_id="text_id_1", update_text_request=UpdateTextRequest(title="updated_title", is_published=True)) assert response is not None - assert len(response.available_languages) == 1 - assert response.available_languages[0].language == "bo" - assert response.available_languages[0].language_code == "bo" - assert response.available_languages[0].version_count == 5 - - -# ============================================================================ -# get_language_versions Service Tests -# ============================================================================ + # Verify that invalidate was called as fallback + mock_invalidate.assert_called_once_with(text_id="text_id_1") @pytest.mark.asyncio -async def test_get_language_versions_success(): - """Test get_language_versions returns versions for a specific language""" +async def test_update_text_details_with_cache_invalidation(): + """Test update_text_details updates text successfully""" text_id = "123e4567-e89b-12d3-a456-426614174000" - language = "bo" - group_id = "group_id_1" + update_request = UpdateTextRequest( + title="Updated Title", + language="bo" + ) - mock_text_detail = TextDTO( + updated_text = TextDTO( id=text_id, - title="Test Text", + title="Updated Title", language="bo", - group_id=group_id, + group_id="group_id_1", type="version", is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", published_by="pecha", categories=[], views=0 ) + with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ + patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=updated_text), \ + patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=updated_text) as mock_update, \ + patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock): + + result = await update_text_details(text_id=text_id, update_text_request=update_request) + + assert result is not None + assert result.title == "Updated Title" + mock_update.assert_called_once_with(text_id=text_id, update_text_request=update_request) + +# Tests for new private functions +def test_group_texts_by_group_id_with_language_sorting(): + """Test _group_texts_by_group_id groups texts and sorts by language preference""" + from pecha_api.texts.texts_service import _group_texts_by_group_id + + # Create mock texts with different group_ids and languages mock_texts = [ TextDTO( - id=text_id, - title="Version 1", + id="text_id_1", + title="Text 1 Bo", language="bo", - group_id=group_id, + group_id="group_1", + type="version", + is_published=True, + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", + published_by="pecha", + categories=[], + views=0 + ), + TextDTO( + id="text_id_2", + title="Text 2 En", + language="en", + group_id="group_1", + type="version", + is_published=True, + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", + published_by="pecha", + categories=[], + views=0 + ), + TextDTO( + id="text_id_3", + title="Text 3 Zh", + language="zh", + group_id="group_1", type="version", is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", published_by="pecha", categories=[], - views=0, - source_link="https://source-1.com", - ranking=1, - license="CC0" + views=0 ), TextDTO( - id="text-2-uuid", - title="Version 2", + id="text_id_4", + title="Text 4 Bo Group2", language="bo", - group_id=group_id, + group_id="group_2", + type="version", + is_published=True, + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", + published_by="pecha", + categories=[], + views=0 + ) + ] + + # Test with 'en' as preferred language + result = _group_texts_by_group_id(texts=mock_texts, language="en") + + # Verify grouping + assert len(result) == 2 + assert "group_1" in result + assert "group_2" in result + assert len(result["group_1"]) == 3 + assert len(result["group_2"]) == 1 + + # Verify sorting - 'en' should be first for group_1 + assert result["group_1"][0].language == "en" + assert result["group_1"][1].language == "bo" + assert result["group_1"][2].language == "zh" + + +def test_group_texts_by_group_id_with_bo_preference(): + """Test _group_texts_by_group_id with Tibetan (bo) language preference""" + from pecha_api.texts.texts_service import _group_texts_by_group_id + + mock_texts = [ + TextDTO( + id="text_id_1", + title="Text En", + language="en", + group_id="group_1", type="version", is_published=True, - created_date="2025-01-02T00:00:00", - updated_date="2025-01-02T00:00:00", - published_date="2025-01-02T00:00:00", + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", published_by="pecha", categories=[], - views=0, - source_link="https://source-2.com", - ranking=2, - license="CC BY" + views=0 ), TextDTO( - id="text-3-uuid", - title="English Version", - language="en", - group_id=group_id, - type="translation", + id="text_id_2", + title="Text Bo", + language="bo", + group_id="group_1", + type="version", is_published=True, - created_date="2025-01-03T00:00:00", - updated_date="2025-01-03T00:00:00", - published_date="2025-01-03T00:00:00", + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", published_by="pecha", categories=[], views=0 ) ] - mock_table_of_content = TableOfContent( - id="toc-1", - text_id=text_id, - type=TableOfContentType.TEXT, - sections=[] - ) + result = _group_texts_by_group_id(texts=mock_texts, language="bo") - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[mock_table_of_content]): - - response = await get_language_versions(text_id=text_id, language=language) - - assert response is not None - assert isinstance(response, VersionsResponse) - assert response.text_id == text_id - assert response.language == language - assert len(response.available_versions) == 2 - assert all(v.language == "bo" for v in response.available_versions) - - selected_versions = [v for v in response.available_versions if v.is_selected] - assert len(selected_versions) == 1 - assert selected_versions[0].id == text_id + # Verify 'bo' is first + assert result["group_1"][0].language == "bo" + assert result["group_1"][1].language == "en" -@pytest.mark.asyncio -async def test_get_language_versions_text_not_found(): - """Test get_language_versions raises 404 when text doesn't exist""" - text_id = "non-existent-id" - language = "bo" +def test_get_language_priority_via_textutils(): + """Test language priority through TextUtils (used by service layer)""" + from pecha_api.texts.texts_utils import TextUtils - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_language_versions(text_id=text_id, language=language) - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE + # Test with 'bo' preference + assert TextUtils.get_language_priority("bo", "bo") == 0 + assert TextUtils.get_language_priority("en", "bo") == 1 + assert TextUtils.get_language_priority("zh", "bo") == 2 + assert TextUtils.get_language_priority("unknown", "bo") == 999 + + # Test with 'en' preference + assert TextUtils.get_language_priority("en", "en") == 0 + assert TextUtils.get_language_priority("bo", "en") == 1 + assert TextUtils.get_language_priority("zh", "en") == 2 + assert TextUtils.get_language_priority("unknown", "en") == 999 + + # Test with 'zh' preference + assert TextUtils.get_language_priority("zh", "zh") == 0 + assert TextUtils.get_language_priority("en", "zh") == 1 + assert TextUtils.get_language_priority("bo", "zh") == 2 -@pytest.mark.asyncio -async def test_get_language_versions_empty_for_language(): - """Test get_language_versions returns empty list when no versions for requested language""" - text_id = "123e4567-e89b-12d3-a456-426614174000" - language = "zh" - group_id = "group_id_1" +def test_get_language_priority_with_none_via_textutils(): + """Test TextUtils.get_language_priority handles None text_language""" + from pecha_api.texts.texts_utils import TextUtils - mock_text_detail = TextDTO( - id=text_id, - title="Test Text", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="pecha", - categories=[], - views=0 - ) + # None language should get default priority + assert TextUtils.get_language_priority(None, "bo") == 999 + assert TextUtils.get_language_priority(None, "en") == 999 + + +def test_group_texts_by_group_id_empty_texts(): + """Test _group_texts_by_group_id with empty text list""" + from pecha_api.texts.texts_service import _group_texts_by_group_id - mock_texts = [ - TextDTO( - id=text_id, - title="Tibetan Version", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="pecha", - categories=[], - views=0 - ), - TextDTO( - id="text-2-uuid", - title="English Version", - language="en", - group_id=group_id, - type="translation", - is_published=True, - created_date="2025-01-02T00:00:00", - updated_date="2025-01-02T00:00:00", - published_date="2025-01-02T00:00:00", - published_by="pecha", - categories=[], - views=0 - ) - ] + result = _group_texts_by_group_id(texts=[], language="bo") - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts): - - response = await get_language_versions(text_id=text_id, language=language) - - assert response is not None - assert isinstance(response, VersionsResponse) - assert response.text_id == text_id - assert response.language == language - assert len(response.available_versions) == 0 + assert result == {} -@pytest.mark.asyncio -async def test_get_language_versions_with_table_of_contents(): - """Test get_language_versions includes table_of_contents for each version""" - text_id = "123e4567-e89b-12d3-a456-426614174000" - language = "bo" - group_id = "group_id_1" - - mock_text_detail = TextDTO( - id=text_id, - title="Test Text", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="pecha", - categories=[], - views=0 - ) +def test_group_texts_by_group_id_single_group(): + """Test _group_texts_by_group_id with single group""" + from pecha_api.texts.texts_service import _group_texts_by_group_id mock_texts = [ TextDTO( - id=text_id, - title="Version 1", + id="text_id_1", + title="Text 1", language="bo", - group_id=group_id, + group_id="single_group", type="version", is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", + created_date="2025-03-21 09:40:34.025024", + updated_date="2025-03-21 09:40:34.025035", + published_date="2025-03-21 09:40:34.025038", published_by="pecha", categories=[], views=0 ) ] - mock_table_of_contents = [ - TableOfContent(id="toc-1", text_id=text_id, type=TableOfContentType.TEXT, sections=[]), - TableOfContent(id="toc-2", text_id=text_id, type=TableOfContentType.TEXT, sections=[]) - ] + result = _group_texts_by_group_id(texts=mock_texts, language="bo") - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents): - - response = await get_language_versions(text_id=text_id, language=language) - - assert response is not None - assert len(response.available_versions) == 1 - assert len(response.available_versions[0].table_of_contents) == 2 - assert "toc-1" in response.available_versions[0].table_of_contents - assert "toc-2" in response.available_versions[0].table_of_contents + assert len(result) == 1 + assert "single_group" in result + assert len(result["single_group"]) == 1 + assert result["single_group"][0].id == "text_id_1" -# ============================================================================ -# get_version_info Service Tests -# ============================================================================ @pytest.mark.asyncio -async def test_get_version_info_success(): - """Test get_version_info returns version details for a valid version_id""" - version_id = "123e4567-e89b-12d3-a456-426614174000" - group_id = "group_id_1" +async def test_replace_pecha_segment_id_with_segment_id_success(): + """Test replace_pecha_segment_id_with_segment_id converts pecha segment IDs to database segment IDs""" + text_id = "text_id_1" - mock_text_detail = TextDTO( - id=version_id, - title="Test Version", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="pecha", - categories=[], - views=0, - source_link="https://source.com", - ranking=1, - license="CC0" - ) + # Mock segments from database + class MockSegment: + def __init__(self, segment_id, pecha_segment_id): + self.id = segment_id + self.pecha_segment_id = pecha_segment_id - mock_table_of_contents = [ - TableOfContent(id="toc-1", text_id=version_id, type=TableOfContentType.TEXT, sections=[]), - TableOfContent(id="toc-2", text_id=version_id, type=TableOfContentType.TEXT, sections=[]) + mock_segments = [ + MockSegment("db_seg_1", "pecha_seg_1"), + MockSegment("db_seg_2", "pecha_seg_2") ] - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents): - - response = await get_version_info(version_id=version_id) - - assert response is not None - assert isinstance(response, VersionDetail) - assert response.id == version_id - assert response.title == "Test Version" - assert response.language == "bo" - assert response.type == "version" - assert response.group_id == group_id - assert response.is_published == True - assert response.is_selected == True - assert len(response.table_of_contents) == 2 - assert "toc-1" in response.table_of_contents - assert "toc-2" in response.table_of_contents - assert response.source_link == "https://source.com" - assert response.ranking == 1 - assert response.license == "CC0" - - -@pytest.mark.asyncio -async def test_get_version_info_text_not_found(): - """Test get_version_info raises 404 when version doesn't exist""" - version_id = "non-existent-id" - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False): - with pytest.raises(HTTPException) as exc_info: - await get_version_info(version_id=version_id) - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE - - -@pytest.mark.asyncio -async def test_get_version_info_empty_table_of_contents(): - """Test get_version_info returns empty table_of_contents when no TOC exists""" - version_id = "123e4567-e89b-12d3-a456-426614174000" - group_id = "group_id_1" - - mock_text_detail = TextDTO( - id=version_id, - title="Version without TOC", - language="bo", - group_id=group_id, - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="pecha", - categories=[], - views=0 + incoming_toc = TableOfContent( + text_id=text_id, + type=TableOfContentType.TEXT, + sections=[ + Section( + id="section_1", + title="Section 1", + section_number=1, + segments=[ + TextSegment(segment_id="pecha_seg_1", segment_number=1), + TextSegment(segment_id="pecha_seg_2", segment_number=2) + ] + ) + ] ) - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]): - - response = await get_version_info(version_id=version_id) + with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments): + result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc) - assert response is not None - assert isinstance(response, VersionDetail) - assert response.id == version_id - assert response.table_of_contents == [] + assert result is not None + assert isinstance(result, TableOfContent) + assert result.text_id == text_id + assert result.type == TableOfContentType.TEXT + assert len(result.sections) == 1 + assert len(result.sections[0].segments) == 2 + # Verify segment IDs were replaced + assert result.sections[0].segments[0].segment_id == "db_seg_1" + assert result.sections[0].segments[1].segment_id == "db_seg_2" @pytest.mark.asyncio -async def test_get_version_info_with_optional_fields_none(): - """Test get_version_info handles optional fields as None""" - version_id = "123e4567-e89b-12d3-a456-426614174000" - group_id = "group_id_1" +async def test_replace_pecha_segment_id_with_segment_id_multiple_sections(): + """Test replace_pecha_segment_id_with_segment_id with multiple sections""" + text_id = "text_id_1" - mock_text_detail = TextDTO( - id=version_id, - title="Version with None fields", - language="bo", - group_id=group_id, - type="version", - is_published=False, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="pecha", - categories=[], - views=0, - source_link=None, - ranking=None, - license=None - ) + class MockSegment: + def __init__(self, segment_id, pecha_segment_id): + self.id = segment_id + self.pecha_segment_id = pecha_segment_id - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]): - - response = await get_version_info(version_id=version_id) - - assert response is not None - assert isinstance(response, VersionDetail) - assert response.source_link is None - assert response.ranking is None - assert response.license is None - assert response.parent_id is None - assert response.priority is None - assert response.is_published == False - - -@pytest.mark.asyncio -async def test_get_version_info_translation_type(): - """Test get_version_info works with translation type versions""" - version_id = "123e4567-e89b-12d3-a456-426614174000" - group_id = "group_id_1" + mock_segments = [ + MockSegment("db_seg_1", "pecha_seg_1"), + MockSegment("db_seg_2", "pecha_seg_2"), + MockSegment("db_seg_3", "pecha_seg_3") + ] - mock_text_detail = TextDTO( - id=version_id, - title="English Translation", - language="en", - group_id=group_id, - type="translation", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="translator", - categories=["category-1"], - views=100, - source_link="https://translation.com", - ranking=2, - license="CC BY" + incoming_toc = TableOfContent( + text_id=text_id, + type=TableOfContentType.TEXT, + sections=[ + Section( + id="section_1", + title="Section 1", + section_number=1, + segments=[ + TextSegment(segment_id="pecha_seg_1", segment_number=1) + ] + ), + Section( + id="section_2", + title="Section 2", + section_number=2, + segments=[ + TextSegment(segment_id="pecha_seg_2", segment_number=1), + TextSegment(segment_id="pecha_seg_3", segment_number=2) + ] + ) + ] ) - mock_table_of_contents = [ - TableOfContent(id="toc-1", text_id=version_id, type=TableOfContentType.TEXT, sections=[]) - ] - - with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \ - patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \ - patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents): - - response = await get_version_info(version_id=version_id) + with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments): + result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc) - assert response is not None - assert isinstance(response, VersionDetail) - assert response.type == "translation" - assert response.language == "en" - assert response.title == "English Translation" - assert len(response.table_of_contents) == 1 \ No newline at end of file + assert result is not None + assert len(result.sections) == 2 + assert result.sections[0].segments[0].segment_id == "db_seg_1" + assert result.sections[1].segments[0].segment_id == "db_seg_2" + assert result.sections[1].segments[1].segment_id == "db_seg_3" \ No newline at end of file diff --git a/tests/texts/test_texts_views.py b/tests/texts/test_texts_views.py deleted file mode 100644 index e03358fb5..000000000 --- a/tests/texts/test_texts_views.py +++ /dev/null @@ -1,1348 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi import HTTPException, status -from fastapi.testclient import TestClient -from httpx import AsyncClient, ASGITransport - -from pecha_api.app import api -from pecha_api.texts.texts_response_models import ( - CreateTextRequest, - TableOfContentResponse, - TextDTO, - TextVersionResponse, - TextVersion, - TableOfContent, - TableOfContentType, - Section, - TextDetailsRequest, - TextsByPechaTextIdsRequest, - TitleSearchResult, - LanguageResponse, - AvailableLanguage, - VersionDetail, - VersionsResponse -) - -client = TestClient(api) - -# Test data -MOCK_TEXT_DTO = TextDTO( - id="123e4567-e89b-12d3-a456-426614174000", - pecha_text_id="test_pecha_id", - title="Test Text", - language="bo", - group_id="123e4567-e89b-12d3-a456-426614174000", - type="version", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - categories=[], - views=0, - source_link="https://test-source.com", - ranking=1, - license="CC0" -) - -# Create a simple section for the table of contents -mock_section = Section( - id="123e4567-e89b-12d3-a456-426614174002", - title="Chapter 1", - section_number=1, - parent_id=None, - segments=[], - sections=[], - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00" -) - -# Create a table of content with the section -MOCK_TABLE_OF_CONTENT = TableOfContent( - id="123e4567-e89b-12d3-a456-426614174001", - text_id="123e4567-e89b-12d3-a456-426614174000", - type=TableOfContentType.TEXT, - sections=[mock_section] -) - - - -# Create response objects -# Create a proper DetailTableOfContentResponse -MOCK_TABLE_OF_CONTENT_RESPONSE = TableOfContentResponse( - text_detail=MOCK_TEXT_DTO, - contents=[MOCK_TABLE_OF_CONTENT] -) - -# Create a TextVersion instance for the response -MOCK_TEXT_VERSION = TextVersion( - id="123e4567-e89b-12d3-a456-426614174003", - title="Test Text Version", - parent_id=None, - priority=1, - language="bo", - type="version", - group_id="123e4567-e89b-12d3-a456-426614174004", - table_of_contents=[], - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user" -) - -MOCK_TEXT_VERSION_RESPONSE = TextVersionResponse( - text=MOCK_TEXT_DTO, - versions=[MOCK_TEXT_VERSION] -) - -VALID_TOKEN = "valid_token_123" - -@pytest.mark.asyncio -async def test_get_text_by_text_id(mocker): - """Test GET /texts with text_id parameter""" - mock_get_text = mocker.patch( - 'pecha_api.texts.texts_views.get_text_by_text_id_or_collection', - new_callable=AsyncMock, - return_value=MOCK_TEXT_DTO - ) - - test_text_id = "123e4567-e89b-12d3-a456-426614174000" - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get(f"/texts?text_id={test_text_id}&language=bo&skip=0&limit=10") - - # Assertions - assert response.status_code == 200 - response_data = response.json() - assert isinstance(response_data, dict) - assert response_data["id"] == MOCK_TEXT_DTO.id - assert response_data["title"] == MOCK_TEXT_DTO.title - assert response_data["language"] == MOCK_TEXT_DTO.language - mock_get_text.assert_called_once_with( - text_id="123e4567-e89b-12d3-a456-426614174000", - collection_id=None, - language="bo", - skip=0, - limit=10 - ) - -@pytest.mark.asyncio -async def test_get_text_by_collection_id(mocker): - """Test GET /texts with collection_id parameter""" - # Mock the service function - mock_get_text = mocker.patch( - 'pecha_api.texts.texts_views.get_text_by_text_id_or_collection', - new_callable=AsyncMock, - return_value=MOCK_TEXT_DTO - ) - - # The collection ID that will be used in the request - test_collection_id = "123e4567-e89b-12d3-a456-426614174001" - - # Make the request - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get(f"/texts?collection_id={test_collection_id}&language=bo&skip=0&limit=10") - - # Assertions - assert response.status_code == 200 - response_data = response.json() - assert isinstance(response_data, dict) - assert response_data["id"] == MOCK_TEXT_DTO.id - assert response_data["title"] == MOCK_TEXT_DTO.title - assert response_data["language"] == MOCK_TEXT_DTO.language - # Remove the contents check as it's not part of the TextDTO model - mock_get_text.assert_called_once_with( - text_id=None, - collection_id=test_collection_id, - language="bo", - skip=0, - limit=10 - ) - -@pytest.mark.asyncio -async def test_create_text_success(mocker): - """Test POST /texts with valid data""" - # Mock the service function - mock_create_text = mocker.patch( - 'pecha_api.texts.texts_views.create_new_text', - new_callable=AsyncMock, - return_value=MOCK_TEXT_DTO - ) - - # Test data - match the CreateTextRequest model - # Note: The mock returns MOCK_TEXT_DTO which has title="Test Text" - create_data = { - "pecha_text_id": "test_pecha_id", - "title": "Test Text", # Match the mock data - "language": "bo", - "isPublished": True, - "group_id": "123e4567-e89b-12d3-a456-426614174000", - "type": "version", - "published_by": "test_user", - "categories": [], - "source_link": "https://test-source.com", - "ranking": 1, - "license": "CC0" - } - - # Make the request - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/texts", - json=create_data, - headers={"Authorization": f"Bearer {VALID_TOKEN}"} - ) - - # Assertions - 201 Created is the correct status code for resource creation - assert response.status_code == 201 - response_data = response.json() - assert response_data["title"] == create_data["title"] - assert response_data["language"] == create_data["language"] - assert response_data["type"] == create_data["type"] - assert response_data["is_published"] == create_data["isPublished"] - mock_create_text.assert_called_once() - call_args = mock_create_text.call_args[1] - assert call_args["token"] == VALID_TOKEN - assert isinstance(call_args["create_text_request"], CreateTextRequest) - -@pytest.mark.asyncio -async def test_get_versions_success(mocker): - """Test GET /texts/{text_id}/versions""" - # Mock the service function - mock_get_versions = mocker.patch( - 'pecha_api.texts.texts_views.get_text_versions_by_group_id', - new_callable=AsyncMock, - return_value={ - "text": MOCK_TEXT_DTO, - "versions": [MOCK_TEXT_VERSION.model_dump()] - } - ) - - # The text ID that will be used in the request - test_text_id = "123e4567-e89b-12d3-a456-426614174000" - - # Make the request - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get(f"/texts/{test_text_id}/versions?language=bo&skip=0&limit=10") - - # Assertions - assert response.status_code == 200 - response_data = response.json() - assert "text" in response_data - assert "versions" in response_data - assert isinstance(response_data["versions"], list) - if response_data["versions"]: # Check only if versions list is not empty - version = response_data["versions"][0] - assert "id" in version - assert "title" in version - assert "language" in version - mock_get_versions.assert_called_once_with( - text_id=test_text_id, - language="bo", - skip=0, - limit=10 - ) - -@pytest.mark.asyncio -async def test_get_contents_success(mocker): - """Test GET /texts/{text_id}/contents""" - # Mock the service function - mock_get_contents = mocker.patch( - 'pecha_api.texts.texts_views.get_table_of_contents_by_text_id', - new_callable=AsyncMock, - return_value=MOCK_TABLE_OF_CONTENT_RESPONSE - ) - - # Make the request - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/contents") - - # Assertions - assert response.status_code == 200 - response_data = response.json() - assert "text_detail" in response_data - assert "contents" in response_data - assert isinstance(response_data["contents"], list) - assert len(response_data["contents"]) > 0 - mock_get_contents.assert_called_once_with( - text_id="123e4567-e89b-12d3-a456-426614174000", - language=None, - skip=0, - limit=10 - ) - - -@pytest.mark.asyncio -async def test_create_table_of_content_success(mocker): - """Test POST /texts/table-of-content""" - # Mock the service function - mock_create_toc = mocker.patch( - 'pecha_api.texts.texts_views.create_table_of_content', - new_callable=AsyncMock, - return_value=MOCK_TABLE_OF_CONTENT - ) - - # Test data - match the TableOfContent model - toc_data = { - "id": "123e4567-e89b-12d3-a456-426614174001", - "text_id": "123e4567-e89b-12d3-a456-426614174000", - "type": "text", - "sections": [ - { - "id": "123e4567-e89b-12d3-a456-426614174002", - "title": "Chapter 1", - "section_number": 1, - "parent_id": None, - "segments": [], - "sections": [], - "created_date": "2025-01-01T00:00:00", - "updated_date": "2025-01-01T00:00:00", - "published_date": "2025-01-01T00:00:00", - "published_by": "test_user" - } - ] - } - - # Make the request - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/texts/table-of-content", - json=toc_data, - headers={"Authorization": f"Bearer {VALID_TOKEN}"} - ) - - # Assertions - assert response.status_code == 200 - response_data = response.json() - assert "id" in response_data - assert "text_id" in response_data - assert "sections" in response_data - assert response_data["text_id"] == toc_data["text_id"] - mock_create_toc.assert_called_once() - call_args = mock_create_toc.call_args[1] - assert call_args["token"] == VALID_TOKEN - assert isinstance(call_args["table_of_content_request"], TableOfContent) - -# Error case tests - -@pytest.mark.asyncio -async def test_create_text_unauthorized(): - """Test POST /texts without authentication""" - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post("/texts", json={"title": "Test"}) - assert response.status_code == 403 - -@pytest.mark.asyncio -async def test_create_table_of_content_unauthorized(): - """Test POST /texts/table-of-content without authentication""" - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post("/texts/table-of-content", json={"text_id": "123e4567-e89b-12d3-a456-426614174000"}) - assert response.status_code == 403 - -@pytest.mark.asyncio -async def test_get_contents_not_found(mocker): - """Test GET /texts/{text_id}/contents with non-existent text""" - mocker.patch( - 'pecha_api.texts.texts_views.get_table_of_contents_by_text_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Text not found") - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/non_existent_id/contents") - - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == "Text not found" - -@pytest.mark.asyncio -async def test_get_commentaries_success(mocker): - """Test GET /texts/{text_id}/commentaries with valid text_id""" - mock_commentary_1 = TextDTO( - id="commentary-1-uuid", - pecha_text_id="commentary_pecha_1", - title="Commentary on Heart Sutra", - language="bo", - group_id="123e4567-e89b-12d3-a456-426614174000", - type="commentary", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="commentator_1", - categories=["123e4567-e89b-12d3-a456-426614174000"], - views=100, - source_link="https://commentary-source-1.com", - ranking=1, - license="CC0" - ) - - mock_commentary_2 = TextDTO( - id="commentary-2-uuid", - pecha_text_id="commentary_pecha_2", - title="Another Commentary on Heart Sutra", - language="bo", - group_id="123e4567-e89b-12d3-a456-426614174000", - type="commentary", - is_published=True, - created_date="2025-01-02T00:00:00", - updated_date="2025-01-02T00:00:00", - published_date="2025-01-02T00:00:00", - published_by="commentator_2", - categories=["123e4567-e89b-12d3-a456-426614174000"], - views=50, - source_link="https://commentary-source-2.com", - ranking=2, - license="CC0" - ) - - mock_commentaries = [mock_commentary_1, mock_commentary_2] - - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - return_value=mock_commentaries - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": 0, "limit": 10} - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 2 - assert data[0]["title"] == "Commentary on Heart Sutra" - assert data[0]["type"] == "commentary" - assert data[1]["title"] == "Another Commentary on Heart Sutra" - - mock_get_commentaries.assert_called_once_with( - text_id="123e4567-e89b-12d3-a456-426614174000", - skip=0, - limit=10 - ) - - -@pytest.mark.asyncio -async def test_get_commentaries_empty_list(mocker): - """Test GET /texts/{text_id}/commentaries when no commentaries exist""" - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - return_value=[] - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": 0, "limit": 10} - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 0 - assert data == [] - - mock_get_commentaries.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_commentaries_with_pagination(mocker): - """Test GET /texts/{text_id}/commentaries with pagination parameters""" - mock_commentary = TextDTO( - id="commentary-uuid", - pecha_text_id="commentary_pecha", - title="Paginated Commentary", - language="bo", - group_id="123e4567-e89b-12d3-a456-426614174000", - type="commentary", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="commentator", - categories=["123e4567-e89b-12d3-a456-426614174000"], - views=10, - source_link="https://commentary-source.com", - ranking=1, - license="CC0" - ) - - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - return_value=[mock_commentary] - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": 5, "limit": 20} - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 1 - - mock_get_commentaries.assert_called_once_with( - text_id="123e4567-e89b-12d3-a456-426614174000", - skip=5, - limit=20 - ) - - -@pytest.mark.asyncio -async def test_get_commentaries_text_not_found(mocker): - """Test GET /texts/{text_id}/commentaries with non-existent text""" - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - side_effect=HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Text not found" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/non-existent-text-id/commentaries", - params={"skip": 0, "limit": 10} - ) - - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == "Text not found" - - mock_get_commentaries.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_commentaries_default_pagination(mocker): - """Test GET /texts/{text_id}/commentaries with default pagination values""" - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - return_value=[] - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries" - ) - - assert response.status_code == status.HTTP_200_OK - - mock_get_commentaries.assert_called_once_with( - text_id="123e4567-e89b-12d3-a456-426614174000", - skip=0, - limit=10 - ) - - -@pytest.mark.asyncio -async def test_get_commentaries_negative_skip(mocker): - """Test GET /texts/{text_id}/commentaries with negative skip parameter""" - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": -1, "limit": 10} - ) - - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -@pytest.mark.asyncio -async def test_get_commentaries_limit_exceeds_max(mocker): - """Test GET /texts/{text_id}/commentaries with limit exceeding maximum""" - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": 0, "limit": 150} - ) - - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -@pytest.mark.asyncio -async def test_get_commentaries_single_commentary(mocker): - """Test GET /texts/{text_id}/commentaries with single commentary""" - mock_commentary = TextDTO( - id="single-commentary-uuid", - pecha_text_id="single_commentary_pecha", - title="Single Commentary", - language="en", - group_id="123e4567-e89b-12d3-a456-426614174000", - type="commentary", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="single_commentator", - categories=["123e4567-e89b-12d3-a456-426614174000"], - views=5, - source_link="https://single-commentary.com", - ranking=1, - license="CC BY" - ) - - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - return_value=[mock_commentary] - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": 0, "limit": 10} - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 1 - assert data[0]["id"] == "single-commentary-uuid" - assert data[0]["title"] == "Single Commentary" - assert data[0]["type"] == "commentary" - assert data[0]["language"] == "en" - assert data[0]["categories"] == ["123e4567-e89b-12d3-a456-426614174000"] - - -@pytest.mark.asyncio -async def test_get_commentaries_with_optional_fields_none(mocker): - """Test GET /texts/{text_id}/commentaries with optional fields as None""" - mock_commentary = TextDTO( - id="commentary-optional-none-uuid", - pecha_text_id=None, - title="Commentary with None fields", - language="bo", - group_id="123e4567-e89b-12d3-a456-426614174000", - type="commentary", - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="commentator", - categories=["123e4567-e89b-12d3-a456-426614174000"], - views=0, - source_link=None, - ranking=None, - license=None - ) - - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - return_value=[mock_commentary] - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": 0, "limit": 10} - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data) == 1 - assert data[0]["pecha_text_id"] is None - assert data[0]["source_link"] is None - assert data[0]["ranking"] is None - assert data[0]["license"] is None - assert data[0]["views"] == 0 - - -@pytest.mark.asyncio -async def test_get_commentaries_service_error(mocker): - """Test GET /texts/{text_id}/commentaries when service raises unexpected error""" - mock_get_commentaries = mocker.patch( - 'pecha_api.texts.texts_views.get_commentaries_by_text_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get( - "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries", - params={"skip": 0, "limit": 10} - ) - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.json()["detail"] == "Internal server error" - - -@pytest.mark.asyncio -async def test_get_contents_with_details_success(mocker): - """Test POST /texts/{text_id}/details with valid request""" - # Create mock response with all required fields - mock_detail_response = { - "text_detail": MOCK_TEXT_DTO.model_dump(), - "content": { - "id": "123e4567-e89b-12d3-a456-426614174001", - "text_id": "123e4567-e89b-12d3-a456-426614174000", - "sections": [] - }, - "size": 10, - "pagination_direction": "next", - "current_segment_position": 1, - "total_segments": 100 - } - - # Mock the service function - mock_get_details = mocker.patch( - 'pecha_api.texts.texts_views.get_text_details_by_text_id', - new_callable=AsyncMock, - return_value=mock_detail_response - ) - - # Test data - test_text_id = "123e4567-e89b-12d3-a456-426614174000" - details_request = { - "version_id": "123e4567-e89b-12d3-a456-426614174003", - "content_id": "123e4567-e89b-12d3-a456-426614174001", - "segment_id": "123e4567-e89b-12d3-a456-426614174005", - "size": 10, - "direction": "next" - } - - # Make the request - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - f"/texts/{test_text_id}/details", - json=details_request - ) - - # Assertions - assert response.status_code == 200 - response_data = response.json() - assert "text_detail" in response_data - assert "content" in response_data - assert "size" in response_data - assert "pagination_direction" in response_data - assert "current_segment_position" in response_data - assert "total_segments" in response_data - assert response_data["text_detail"]["id"] == MOCK_TEXT_DTO.id - assert response_data["size"] == 10 - assert response_data["total_segments"] == 100 - mock_get_details.assert_called_once() - call_args = mock_get_details.call_args[1] - assert call_args["text_id"] == test_text_id - assert isinstance(call_args["text_details_request"], TextDetailsRequest) - - -@pytest.mark.asyncio -async def test_get_contents_with_details_invalid_text(mocker): - """Test POST /texts/{text_id}/details with non-existent text""" - mocker.patch( - 'pecha_api.texts.texts_views.get_text_details_by_text_id', - side_effect=HTTPException(status_code=404, detail="Text not found") - ) - - details_request = { - "version_id": "123e4567-e89b-12d3-a456-426614174003", - "content_id": "123e4567-e89b-12d3-a456-426614174001", - "segment_id": "123e4567-e89b-12d3-a456-426614174005", - "size": 10, - "direction": "next" - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/texts/non_existent_id/details", - json=details_request - ) - - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_get_versions_not_found(mocker): - """Test GET /texts/{text_id}/versions with non-existent text""" - mocker.patch( - 'pecha_api.texts.texts_views.get_text_versions_by_group_id', - side_effect=HTTPException(status_code=404, detail="Text not found") - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/non_existent_id/versions") - - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_get_text_invalid_parameters(mocker): - """Test GET /texts with invalid pagination parameters""" - mock_get_text = mocker.patch( - 'pecha_api.texts.texts_views.get_text_by_text_id_or_collection', - new_callable=AsyncMock, - return_value=MOCK_TEXT_DTO - ) - - # Test with negative skip - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts?text_id=123&skip=-1&limit=10") - - # FastAPI will validate and return 422 for invalid parameters - assert response.status_code in [200, 422] # Depends on FastAPI validation - - -@pytest.mark.asyncio -async def test_create_text_invalid_data(): - """Test POST /texts with invalid data""" - invalid_data = { - "title": "Test", - # Missing required fields - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/texts", - json=invalid_data, - headers={"Authorization": f"Bearer {VALID_TOKEN}"} - ) - - # Should return 422 for validation error - assert response.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_text_service_error(mocker): - """Test POST /texts when service layer raises an error""" - mocker.patch( - 'pecha_api.texts.texts_views.create_new_text', - side_effect=HTTPException(status_code=400, detail="Invalid group_id") - ) - - create_data = { - "pecha_text_id": "test_pecha_id", - "title": "Test Text", - "language": "bo", - "isPublished": True, - "group_id": "invalid_group_id", - "type": "version", - "published_by": "test_user", - "categories": [], - "source_link": "https://test-source.com", - "ranking": 1, - "license": "CC0" - } - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.post( - "/texts", - json=create_data, - headers={"Authorization": f"Bearer {VALID_TOKEN}"} - ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Invalid group_id" - - -@pytest.mark.asyncio -async def test_search_titles_success(mocker): - """Test GET /texts/title-search returns title search results""" - mock_results = [ - TitleSearchResult(id="text_1", title="A Title"), - TitleSearchResult(id="text_2", title="Another Title") - ] - - mock_search_titles = mocker.patch( - "pecha_api.texts.texts_views.get_titles_and_ids_by_query", - new_callable=AsyncMock, - return_value=mock_results - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/title-search?title=Test&limit=20&offset=0") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data == [ - {"id": "text_1", "title": "A Title"}, - {"id": "text_2", "title": "Another Title"} - ] - - mock_search_titles.assert_called_once_with( - title="Test", - author=None, - limit=20, - offset=0 - ) - - -# ============================================================================ -# GET /texts/{text_id}/languages Tests -# ============================================================================ - -@pytest.mark.asyncio -async def test_get_languages_success(mocker): - """Test GET /texts/{text_id}/languages returns available languages""" - mock_response = LanguageResponse( - text_id="123e4567-e89b-12d3-a456-426614174000", - title="Test Text", - available_languages=[ - AvailableLanguage(language="bo", language_code="bo", version_count=3), - AvailableLanguage(language="en", language_code="en", version_count=2), - AvailableLanguage(language="zh", language_code="zh", version_count=1) - ] - ) - - mock_get_languages = mocker.patch( - 'pecha_api.texts.texts_views.get_text_languages', - new_callable=AsyncMock, - return_value=mock_response - ) - - test_text_id = "123e4567-e89b-12d3-a456-426614174000" - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get(f"/texts/{test_text_id}/languages") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["text_id"] == test_text_id - assert data["title"] == "Test Text" - assert len(data["available_languages"]) == 3 - assert data["available_languages"][0]["language"] == "bo" - assert data["available_languages"][0]["version_count"] == 3 - - mock_get_languages.assert_called_once_with(text_id=test_text_id) - - -@pytest.mark.asyncio -async def test_get_languages_empty_list(mocker): - """Test GET /texts/{text_id}/languages when no languages available""" - mock_response = LanguageResponse( - text_id="123e4567-e89b-12d3-a456-426614174000", - title="Test Text", - available_languages=[] - ) - - mocker.patch( - 'pecha_api.texts.texts_views.get_text_languages', - new_callable=AsyncMock, - return_value=mock_response - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["available_languages"] == [] - - -@pytest.mark.asyncio -async def test_get_languages_text_not_found(mocker): - """Test GET /texts/{text_id}/languages with non-existent text""" - mocker.patch( - 'pecha_api.texts.texts_views.get_text_languages', - side_effect=HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Text not found" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/non-existent-id/languages") - - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == "Text not found" - - -@pytest.mark.asyncio -async def test_get_languages_single_language(mocker): - """Test GET /texts/{text_id}/languages with single language""" - mock_response = LanguageResponse( - text_id="123e4567-e89b-12d3-a456-426614174000", - title="Single Language Text", - available_languages=[ - AvailableLanguage(language="bo", language_code="bo", version_count=5) - ] - ) - - mocker.patch( - 'pecha_api.texts.texts_views.get_text_languages', - new_callable=AsyncMock, - return_value=mock_response - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data["available_languages"]) == 1 - assert data["available_languages"][0]["language"] == "bo" - assert data["available_languages"][0]["version_count"] == 5 - - -@pytest.mark.asyncio -async def test_get_languages_service_error(mocker): - """Test GET /texts/{text_id}/languages when service raises error""" - mocker.patch( - 'pecha_api.texts.texts_views.get_text_languages', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages") - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.json()["detail"] == "Internal server error" - - -# ============================================================================ -# GET /texts/{text_id}/languages/{language}/versions Tests -# ============================================================================ - -@pytest.mark.asyncio -async def test_get_language_versions_success(mocker): - """Test GET /texts/{text_id}/languages/{language}/versions returns versions for a language""" - mock_version_1 = VersionDetail( - id="version-1-uuid", - title="Version 1", - parent_id=None, - priority=None, - language="bo", - type="version", - group_id="group-1-uuid", - table_of_contents=["toc-1"], - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - source_link="https://source-1.com", - ranking=1, - license="CC0", - is_selected=True - ) - - mock_version_2 = VersionDetail( - id="version-2-uuid", - title="Version 2", - parent_id=None, - priority=None, - language="bo", - type="version", - group_id="group-1-uuid", - table_of_contents=["toc-2"], - is_published=True, - created_date="2025-01-02T00:00:00", - updated_date="2025-01-02T00:00:00", - published_date="2025-01-02T00:00:00", - published_by="test_user_2", - source_link="https://source-2.com", - ranking=2, - license="CC BY", - is_selected=False - ) - - mock_response = VersionsResponse( - text_id="123e4567-e89b-12d3-a456-426614174000", - language="bo", - available_versions=[mock_version_1, mock_version_2] - ) - - mock_get_versions = mocker.patch( - 'pecha_api.texts.texts_views.get_language_versions', - new_callable=AsyncMock, - return_value=mock_response - ) - - test_text_id = "123e4567-e89b-12d3-a456-426614174000" - test_language = "bo" - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get(f"/texts/{test_text_id}/languages/{test_language}/versions") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["text_id"] == test_text_id - assert data["language"] == test_language - assert len(data["available_versions"]) == 2 - assert data["available_versions"][0]["id"] == "version-1-uuid" - assert data["available_versions"][0]["is_selected"] == True - assert data["available_versions"][1]["id"] == "version-2-uuid" - assert data["available_versions"][1]["is_selected"] == False - - mock_get_versions.assert_called_once_with(text_id=test_text_id, language=test_language) - - -@pytest.mark.asyncio -async def test_get_language_versions_empty_list(mocker): - """Test GET /texts/{text_id}/languages/{language}/versions when no versions for language""" - mock_response = VersionsResponse( - text_id="123e4567-e89b-12d3-a456-426614174000", - language="zh", - available_versions=[] - ) - - mocker.patch( - 'pecha_api.texts.texts_views.get_language_versions', - new_callable=AsyncMock, - return_value=mock_response - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages/zh/versions") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["language"] == "zh" - assert data["available_versions"] == [] - - -@pytest.mark.asyncio -async def test_get_language_versions_text_not_found(mocker): - """Test GET /texts/{text_id}/languages/{language}/versions with non-existent text""" - mocker.patch( - 'pecha_api.texts.texts_views.get_language_versions', - side_effect=HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Text not found" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/non-existent-id/languages/bo/versions") - - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == "Text not found" - - -@pytest.mark.asyncio -async def test_get_language_versions_single_version(mocker): - """Test GET /texts/{text_id}/languages/{language}/versions with single version""" - mock_version = VersionDetail( - id="single-version-uuid", - title="Single Version", - parent_id=None, - priority=1, - language="en", - type="translation", - group_id="group-1-uuid", - table_of_contents=["toc-1", "toc-2"], - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="translator", - source_link="https://translation-source.com", - ranking=1, - license="CC BY-SA", - is_selected=True - ) - - mock_response = VersionsResponse( - text_id="123e4567-e89b-12d3-a456-426614174000", - language="en", - available_versions=[mock_version] - ) - - mocker.patch( - 'pecha_api.texts.texts_views.get_language_versions', - new_callable=AsyncMock, - return_value=mock_response - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages/en/versions") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data["available_versions"]) == 1 - assert data["available_versions"][0]["id"] == "single-version-uuid" - assert data["available_versions"][0]["language"] == "en" - assert data["available_versions"][0]["type"] == "translation" - assert data["available_versions"][0]["is_selected"] == True - - -@pytest.mark.asyncio -async def test_get_language_versions_service_error(mocker): - """Test GET /texts/{text_id}/languages/{language}/versions when service raises error""" - mocker.patch( - 'pecha_api.texts.texts_views.get_language_versions', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages/bo/versions") - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.json()["detail"] == "Internal server error" - - -# ============================================================================ -# GET /texts/versions/{version_id}/info Tests -# ============================================================================ - -@pytest.mark.asyncio -async def test_get_version_info_success(mocker): - """Test GET /texts/versions/{version_id}/info returns version details""" - mock_version = VersionDetail( - id="123e4567-e89b-12d3-a456-426614174000", - title="Test Version", - parent_id=None, - priority=None, - language="bo", - type="version", - group_id="group-1-uuid", - table_of_contents=["toc-1", "toc-2"], - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - source_link="https://source.com", - ranking=1, - license="CC0", - is_selected=True - ) - - mock_get_version_info = mocker.patch( - 'pecha_api.texts.texts_views.get_version_info', - new_callable=AsyncMock, - return_value=mock_version - ) - - test_version_id = "123e4567-e89b-12d3-a456-426614174000" - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get(f"/texts/versions/{test_version_id}/info") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["id"] == test_version_id - assert data["title"] == "Test Version" - assert data["language"] == "bo" - assert data["type"] == "version" - assert data["group_id"] == "group-1-uuid" - assert data["table_of_contents"] == ["toc-1", "toc-2"] - assert data["is_published"] == True - assert data["is_selected"] == True - assert data["source_link"] == "https://source.com" - assert data["ranking"] == 1 - assert data["license"] == "CC0" - - mock_get_version_info.assert_called_once_with(version_id=test_version_id) - - -@pytest.mark.asyncio -async def test_get_version_info_not_found(mocker): - """Test GET /texts/versions/{version_id}/info with non-existent version""" - mocker.patch( - 'pecha_api.texts.texts_views.get_version_info', - side_effect=HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Text not found" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/versions/non-existent-id/info") - - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == "Text not found" - - -@pytest.mark.asyncio -async def test_get_version_info_with_optional_fields_none(mocker): - """Test GET /texts/versions/{version_id}/info with optional fields as None""" - mock_version = VersionDetail( - id="123e4567-e89b-12d3-a456-426614174000", - title="Version with None fields", - parent_id=None, - priority=None, - language="bo", - type="version", - group_id="group-1-uuid", - table_of_contents=[], - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="test_user", - source_link=None, - ranking=None, - license=None, - is_selected=True - ) - - mocker.patch( - 'pecha_api.texts.texts_views.get_version_info', - new_callable=AsyncMock, - return_value=mock_version - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/versions/123e4567-e89b-12d3-a456-426614174000/info") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["parent_id"] is None - assert data["priority"] is None - assert data["source_link"] is None - assert data["ranking"] is None - assert data["license"] is None - assert data["table_of_contents"] == [] - - -@pytest.mark.asyncio -async def test_get_version_info_with_multiple_toc(mocker): - """Test GET /texts/versions/{version_id}/info with multiple table of contents""" - mock_version = VersionDetail( - id="123e4567-e89b-12d3-a456-426614174000", - title="Version with multiple TOC", - parent_id=None, - priority=1, - language="en", - type="translation", - group_id="group-1-uuid", - table_of_contents=["toc-1", "toc-2", "toc-3"], - is_published=True, - created_date="2025-01-01T00:00:00", - updated_date="2025-01-01T00:00:00", - published_date="2025-01-01T00:00:00", - published_by="translator", - source_link="https://translation.com", - ranking=2, - license="CC BY", - is_selected=True - ) - - mocker.patch( - 'pecha_api.texts.texts_views.get_version_info', - new_callable=AsyncMock, - return_value=mock_version - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/versions/123e4567-e89b-12d3-a456-426614174000/info") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data["table_of_contents"]) == 3 - assert data["table_of_contents"] == ["toc-1", "toc-2", "toc-3"] - assert data["type"] == "translation" - assert data["priority"] == 1 - - -@pytest.mark.asyncio -async def test_get_version_info_service_error(mocker): - """Test GET /texts/versions/{version_id}/info when service raises error""" - mocker.patch( - 'pecha_api.texts.texts_views.get_version_info', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - ) - ) - - async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac: - response = await ac.get("/texts/versions/123e4567-e89b-12d3-a456-426614174000/info") - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.json()["detail"] == "Internal server error" From 23466cf1dcdc9a8aed331f2600ac4fe23a21d83e Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Mon, 1 Jun 2026 15:51:36 +0530 Subject: [PATCH 04/29] update --- .../versions/c2653fe6f9de_added_audio_url.py | 6 ++++-- .../c8f7a1d2e3f4_add_start_date_to_plans.py | 16 ++++++++++++---- poetry.lock | 7 ++++--- pyproject.toml | 2 +- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/migrations/versions/c2653fe6f9de_added_audio_url.py b/migrations/versions/c2653fe6f9de_added_audio_url.py index d9dbee6e5..0a9542d9f 100644 --- a/migrations/versions/c2653fe6f9de_added_audio_url.py +++ b/migrations/versions/c2653fe6f9de_added_audio_url.py @@ -69,8 +69,10 @@ def constraint_exists(table_name: str, constraint_name: str) -> bool: op.drop_index('idx_plans_search', table_name='plans', postgresql_using='gin') op.create_index('idx_plans_search', 'plans', [sa.text("to_tsvector('english', title || ' ' || COALESCE(description, ''))")], unique=False, postgresql_using='gin') - # Add audio_url column - op.add_column('sub_tasks', sa.Column('audio_url', sa.String(length=255), nullable=True)) + # Add audio_url column only if it doesn't exist + sub_tasks_columns = {col['name'] for col in inspector.get_columns('sub_tasks')} + if 'audio_url' not in sub_tasks_columns: + op.add_column('sub_tasks', sa.Column('audio_url', sa.String(length=255), nullable=True)) def downgrade() -> None: diff --git a/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py b/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py index bf0f65a7c..fa76d67a6 100644 --- a/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py +++ b/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py @@ -20,10 +20,18 @@ def upgrade() -> None: - op.add_column( - "plans", - sa.Column("start_date", sa.DateTime(timezone=True), nullable=True), - ) + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + + # Add start_date column only if it doesn't exist + plans_columns = {col['name'] for col in inspector.get_columns('plans')} + if 'start_date' not in plans_columns: + op.add_column( + "plans", + sa.Column("start_date", sa.DateTime(timezone=True), nullable=True), + ) def downgrade() -> None: diff --git a/poetry.lock b/poetry.lock index 92fa33679..e21e70f95 100644 --- a/poetry.lock +++ b/poetry.lock @@ -189,6 +189,7 @@ files = [ [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] @@ -1049,7 +1050,7 @@ description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.7" groups = ["main"] -markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" files = [ {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, @@ -3138,5 +3139,5 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" -python-versions = "^3.13" -content-hash = "0a04e981d406043fa3b34d5f04a946938a4c6e701e12342615beec7b3dcd6aa9" +python-versions = "^3.12" +content-hash = "bdfb3bb4e89df682ff0597b88109ca92419f7daa999b50a3855e516972f749cf" diff --git a/pyproject.toml b/pyproject.toml index 3d0d552c7..e6fe7c776 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ "tenzinsamten ",] readme = "README.md" [tool.poetry.dependencies] -python = "^3.13" +python = "^3.12" alembic = "^1.14.0" bcrypt = "^4.2.1" fastapi = "^0.115.5" From e27a085d61d548554c471c6518adbebeb03386da Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Mon, 1 Jun 2026 16:10:57 +0530 Subject: [PATCH 05/29] Fix missing TableOfContentResponse import and migration duplicate column checks --- pecha_api/texts/texts_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pecha_api/texts/texts_service.py b/pecha_api/texts/texts_service.py index 3f6e668e1..fe9601459 100644 --- a/pecha_api/texts/texts_service.py +++ b/pecha_api/texts/texts_service.py @@ -14,6 +14,7 @@ ) from .texts_response_models import ( TableOfContent, + TableOfContentResponse, TableOfContentType, TextDTO, TextSegment, From 36edbfef1b62d8a3bb121c1cabd72205d723a569 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Mon, 1 Jun 2026 16:47:24 +0530 Subject: [PATCH 06/29] Remove v2 prefix and tags from collections and texts routers --- pecha_api/collections/collections_openpecha_views.py | 4 ++-- pecha_api/texts/texts_openpecha_views.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pecha_api/collections/collections_openpecha_views.py b/pecha_api/collections/collections_openpecha_views.py index 0877e93ea..d94e0070a 100644 --- a/pecha_api/collections/collections_openpecha_views.py +++ b/pecha_api/collections/collections_openpecha_views.py @@ -9,8 +9,8 @@ from .collections_openpecha_service import get_collections_from_openpecha collections_v2_router = APIRouter( - prefix="/v2/collections", - tags=["collections-v2"] + prefix="/collections", + tags=["collections"] ) diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 41882335e..47700c833 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -19,8 +19,8 @@ from pecha_api.texts.text_openpecha_response_models import TextDetailResponse texts_v2_router = APIRouter( - prefix="/v2/texts", - tags=["texts-v2"], + prefix="/texts", + tags=["texts"], ) @texts_v2_router.get( From 8c47ad7494ba09aa2bbac4ce0e476a6e8e2e8a11 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Mon, 1 Jun 2026 17:05:05 +0530 Subject: [PATCH 07/29] Add v2 suffix to API router tags for collections, segments, and texts --- pecha_api/collections/collections_openpecha_views.py | 2 +- pecha_api/texts/segments/segments_openpecha_views.py | 2 +- pecha_api/texts/texts_openpecha_views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pecha_api/collections/collections_openpecha_views.py b/pecha_api/collections/collections_openpecha_views.py index d94e0070a..102345569 100644 --- a/pecha_api/collections/collections_openpecha_views.py +++ b/pecha_api/collections/collections_openpecha_views.py @@ -10,7 +10,7 @@ collections_v2_router = APIRouter( prefix="/collections", - tags=["collections"] + tags=["collections-v2"] ) diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py index 1ed0b58cf..b22dba9a9 100644 --- a/pecha_api/texts/segments/segments_openpecha_views.py +++ b/pecha_api/texts/segments/segments_openpecha_views.py @@ -18,7 +18,7 @@ segments_v2_router = APIRouter( prefix="/segments", - tags=["Segments"], + tags=["Segments-v2"], ) @segments_v2_router.get("/{segment_id}", status_code=status.HTTP_200_OK) diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 47700c833..083c8d0e8 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -20,7 +20,7 @@ texts_v2_router = APIRouter( prefix="/texts", - tags=["texts"], + tags=["texts-v2"], ) @texts_v2_router.get( From 99d780d9de21348f26d6be82dad6783d08150bdb Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Tue, 2 Jun 2026 12:11:25 +0530 Subject: [PATCH 08/29] Change collection_id from path parameter to query parameter in texts endpoint --- pecha_api/texts/texts_openpecha_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 083c8d0e8..9b67ea866 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -24,13 +24,13 @@ ) @texts_v2_router.get( - "/collection/{collection_id}", + "", status_code=status.HTTP_200_OK, summary="Get texts by collection from OpenPecha", description="Retrieve texts for a collection from OpenPecha API. " ) async def get_texts_by_collection( - collection_id: str, + collection_id: Annotated[str, Query(description="Collection ID to filter texts")], language: Annotated[Optional[str], Query(description="Language code filter")] = None, skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0, limit: Annotated[int, Query(ge=1, le=100, description="Number of records to return")] = 10, From 268064f17154de00473a5ee5004aad14c13dbf0e Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Tue, 2 Jun 2026 14:42:58 +0530 Subject: [PATCH 09/29] Update external API configuration defaults and change text detail endpoint path --- pecha_api/config.py | 6 +++--- pecha_api/texts/texts_openpecha_views.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pecha_api/config.py b/pecha_api/config.py index 7490f7d2a..468b0170f 100644 --- a/pecha_api/config.py +++ b/pecha_api/config.py @@ -74,9 +74,9 @@ APPLICATION = "webuddhist", #pecha api configuration - EXTERNAL_PECHA_API_URL="", - EXTERNAL_DEV_PECHA_API_URL="", - EXTERNAL_OPENPECHA_API_KEY="", + EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app", + EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160", + EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx", EXTERNAL_PECHA_APP_NAME="webuddhist", EXTERNAL_TITLE_SEARCH_API_URL="", diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 9b67ea866..0e29b717c 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -43,7 +43,7 @@ async def get_texts_by_collection( ) @texts_v2_router.get( - "/detail", + "/{text_id}/details", response_model=TextDetailResponse, status_code=status.HTTP_200_OK, summary="Get a text with pagination", From 191c41dfe05bb6c7ee7cdaba7d85cebf43408053 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Tue, 2 Jun 2026 21:15:20 +0530 Subject: [PATCH 10/29] Add new response models for text details endpoint and restructure text detail service to return nested content with sections and segments --- .../texts/text_openpecha_response_models.py | 59 ++++++++++++ pecha_api/texts/texts_openpecha_service.py | 91 +++++++++++++++++-- pecha_api/texts/texts_openpecha_views.py | 8 +- 3 files changed, 147 insertions(+), 11 deletions(-) diff --git a/pecha_api/texts/text_openpecha_response_models.py b/pecha_api/texts/text_openpecha_response_models.py index 3a2db032e..7f175c55f 100644 --- a/pecha_api/texts/text_openpecha_response_models.py +++ b/pecha_api/texts/text_openpecha_response_models.py @@ -83,3 +83,62 @@ class SegmentContentResponse(BaseModel): offset: int limit: int + +# ============================================================================ +# New Response Models for /{text_id}/details endpoint +# ============================================================================ + +class SegmentDTO(BaseModel): + segment_id: str + segment_number: int + content: str + translation: Optional[str] = None + + +class SectionDTO(BaseModel): + id: str + title: str + section_number: int + parent_id: Optional[str] = None + segments: List[SegmentDTO] = [] + sections: List["SectionDTO"] = [] + created_date: Optional[str] = None + updated_date: Optional[str] = None + published_date: Optional[str] = None + + +class ContentDTO(BaseModel): + id: str + text_id: str + sections: List[SectionDTO] = [] + + +class TextDetailDTO(BaseModel): + id: str + pecha_text_id: str + title: str + language: str + group_id: str + type: str + summary: str + is_published: bool + created_date: str + updated_date: str + published_date: str + published_by: str + categories: List[str] = [] + views: int + likes: List[str] = [] + source_link: Optional[str] = None + ranking: Optional[int] = None + license: Optional[str] = None + + +class TextDetailWithContentResponse(BaseModel): + text_detail: TextDetailDTO + content: ContentDTO + size: int + pagination_direction: str + current_segment_position: int + total_segments: int + diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py index 1fb0e43a0..64c3667e5 100644 --- a/pecha_api/texts/texts_openpecha_service.py +++ b/pecha_api/texts/texts_openpecha_service.py @@ -15,7 +15,17 @@ from openpecha_api.text.openpecha_text_service import fetch_texts_by_category, fetch_text_by_id from openpecha_api.collection.openpecha_collection_service import fetch_category_by_id from pecha_api.texts.texts_openpecha_api import fetch_critical_editions, fetch_text_detail, fetch_editions_segmentation, fetch_segmentation_segments, fetch_edition_content -from pecha_api.texts.text_openpecha_response_models import SegmentationSegmentResponseModel, SegmentContentModel, SegmentContentResponse, TextDetailResponse +from pecha_api.texts.text_openpecha_response_models import ( + SegmentationSegmentResponseModel, + SegmentContentModel, + SegmentContentResponse, + TextDetailResponse, + TextDetailWithContentResponse, + TextDetailDTO, + ContentDTO, + SectionDTO, + SegmentDTO, +) logger = logging.getLogger(__name__) @@ -149,7 +159,7 @@ async def get_text_by_id_from_openpecha(text_id: str) -> V2TextDTO: return _map_external_text_to_dto(data, data.get("language")) -async def get_text_detail_by_id(text_id: str, offset: int, limit: int) -> TextDetailResponse: +async def get_text_detail_by_id(text_id: str, offset: int, limit: int) -> TextDetailWithContentResponse: text_detail = await fetch_text_detail(text_id=text_id) edition_details = await fetch_critical_editions(text_id=text_id) if not edition_details: @@ -157,13 +167,80 @@ async def get_text_detail_by_id(text_id: str, offset: int, limit: int) -> TextDe status_code=status.HTTP_404_NOT_FOUND, detail=f"No critical editions found for text with id '{text_id}'", ) - text_detail.edition_details = edition_details segmentations = await fetch_editions_segmentation(edition_id=edition_details[0].id) edition_content = await fetch_edition_content(edition_id=edition_details[0].id) - segments = await fetch_segmentation_segments(segmentation_id=segmentations[0].id, limit=limit, offset=offset) # noqa: F841 - segment_contents = trim_segment_content(edition_content=edition_content.content, segments=segments) - text_detail.segments = segment_contents - return text_detail + segments = await fetch_segmentation_segments(segmentation_id=segmentations[0].id, limit=limit, offset=offset) + + # Build segment DTOs + segment_dtos = [] + for i, segment in enumerate(segments.items): + content = "".join(edition_content.content[line.start:line.end] for line in segment.lines) + segment_dtos.append(SegmentDTO( + segment_id=segment.id, + segment_number=offset + i + 1, + content=content, + translation=None + )) + + # Extract title as string + title_str = _extract_title(text_detail.title, text_detail.language) + date_str = text_detail.date or "" + + # Build TextDetailDTO + text_detail_dto = TextDetailDTO( + id=text_detail.id, + pecha_text_id=text_detail.bdrc or text_detail.id, + title=title_str, + language=text_detail.language, + group_id=text_detail.category_id, + type="version", + summary="", + is_published=True, + created_date=date_str, + updated_date=date_str, + published_date=date_str, + published_by="", + categories=[text_detail.category_id] if text_detail.category_id else [], + views=0, + likes=[], + source_link=text_detail.wiki or "unknown", + ranking=None, + license=text_detail.license or "unknown" + ) + + # Build a single section containing all segments + section = SectionDTO( + id=segmentations[0].id if segmentations else "", + title="1", + section_number=1, + parent_id=None, + segments=segment_dtos, + sections=[], + created_date=None, + updated_date=None, + published_date=None + ) + + # Build ContentDTO + content_dto = ContentDTO( + id=edition_details[0].id, + text_id=text_detail.id, + sections=[section] + ) + + # Calculate total segments (estimate based on has_more) + total_segments = offset + len(segment_dtos) + if segments.has_more: + total_segments += 1 # Indicate there are more + + return TextDetailWithContentResponse( + text_detail=text_detail_dto, + content=content_dto, + size=len(segment_dtos), + pagination_direction="next", + current_segment_position=offset + 1 if segment_dtos else 0, + total_segments=total_segments + ) def trim_segment_content(edition_content: str, segments: SegmentationSegmentResponseModel) -> SegmentContentResponse: diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 0e29b717c..a2f689e25 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -16,7 +16,7 @@ get_text_versions_from_openpecha, get_text_commentaries_from_openpecha, ) -from pecha_api.texts.text_openpecha_response_models import TextDetailResponse +from pecha_api.texts.text_openpecha_response_models import TextDetailWithContentResponse texts_v2_router = APIRouter( prefix="/texts", @@ -42,14 +42,14 @@ async def get_texts_by_collection( limit=limit, ) -@texts_v2_router.get( +@texts_v2_router.post( "/{text_id}/details", - response_model=TextDetailResponse, + response_model=TextDetailWithContentResponse, status_code=status.HTTP_200_OK, summary="Get a text with pagination", description="Retrieve a text by its OpenPecha ID, including local edition details with pagination." ) -async def read_text_by_id(text_id: str, offset: int = Query(default=0), limit: int = Query(default=30)) -> TextDetailResponse: +async def read_text_by_id(text_id: str, offset: int = Query(default=0), limit: int = Query(default=20)) -> TextDetailWithContentResponse: return await get_text_detail_by_id(text_id=text_id, offset=offset, limit=limit) From ca6fa6b5e228bafa628e163257f450c2f04e6726 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Tue, 2 Jun 2026 23:24:19 +0530 Subject: [PATCH 11/29] update --- pecha_api/config.py | 6 +++--- pecha_api/share/static/img/output.png | Bin 0 -> 22066 bytes pecha_api/texts/texts_openpecha_views.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 pecha_api/share/static/img/output.png diff --git a/pecha_api/config.py b/pecha_api/config.py index 468b0170f..7490f7d2a 100644 --- a/pecha_api/config.py +++ b/pecha_api/config.py @@ -74,9 +74,9 @@ APPLICATION = "webuddhist", #pecha api configuration - EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app", - EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160", - EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx", + EXTERNAL_PECHA_API_URL="", + EXTERNAL_DEV_PECHA_API_URL="", + EXTERNAL_OPENPECHA_API_KEY="", EXTERNAL_PECHA_APP_NAME="webuddhist", EXTERNAL_TITLE_SEARCH_API_URL="", diff --git a/pecha_api/share/static/img/output.png b/pecha_api/share/static/img/output.png new file mode 100644 index 0000000000000000000000000000000000000000..f44babc4220456c1744ff355f8d4d07bb033f05c GIT binary patch literal 22066 zcmeFZX*iVO8#io=WJ?ldOGJ{j>}$v_LdcT6vSr^%l0ia}t!!nNU5FT!7*y6U_C3bF zGY0dX)Bkzi<9I$j&-3Mdzx;i16lUhW?&~_w-}zhaD81WiG^Z|{A|fK9(NI^>Cn6$g zAR;1ub&?$ZM_aZkfr#iXRzu~cpa4Lf2EGuu2ns}Ei!(*TPqVu`1<^YX_7)=|C`sKvI>0J^)}A1t$j^f)%^EU?aL?b z)5Dj$*#GYPnwPPn%8ztbH&)iK!sNJf*F2)ei1xER`F5lv#hLXELt5#}3L_&&_|{)U zYEzO@lajg>Q>R)qT^!q#STStHp(=Ud31p~E+pXN)l1VJry4F@GTQ6G#r`q`h$<_U~ z8(J!^~Iky-;;&h$QdcZk;LSmXvffnU1Oikl@ znm(2K?I)f~qEVG&y03$#rA@`h)jCcwM0?vDTot~er(PR6)gml=Ry3U%5sneyJ-5VT zpIn7YmC-2kXG$I4A+;V%UHu8mbpn=r#mYzg%NF^@W!pT4r7g!w(}*V;e6Y$LgX|K@L{f?h?_5t1t_#@ zwt7q4@J*VCx;ffHeTGKtRjsF_5v`?{AY$spKN!o9Ea)rnh6E-@AQUx_- z78Mnq&;Y+%Dm;!CUp=rLabsoRAG;}!OjoK;53-R!2)$?u;*3q_nrMnrvf)LaLsnu_ z#)a#C(4NB3Zf>}k6gHyw+ghT3CZ9LW(^j$jewOf3p%&`aKRCF?zfd@{q{$*sYFUda zl@=e>tH*S`R;f0gvuvo&pGn;cdL6jR(|1JnZPA_?5ju0WyhQdW{oTg4+)G<~Eo)O1 z71~UavdA{>NLOTxR~PyEn^0?8rd~-O;wS5bxZI zS-tavk<4x-H@>~?`}$OS{_53a^N6Np<}+XLQ9{y8PtBdF#`Q=GwP{24#=ef+nRp)A z_Tvg+pXv!#Bq-HW$&7?GdGL)du%0S3Iu$iGdm!;T+La4F*SEI@8^-K(l2&GR0R!D% zj=!9JEwIm@Y{jHc=A?_N*g6|9Xk#Bddq#kC=()0MA?_L`sZQ{M3- zsrDmD+|RDc$8D;pJdoWXT-_T?*rL`+Kbli@WX$(_SLhOSm%nd6UohR#ILN@tBhFv! zJK`Q1&li!s888_9uCzb%+qUWzZr_BZK=S78J8i8ePaHCfCBF~JgO$IHe+{qrY_nzX zbgBF~)C*$rJfUaS0TmuclC&w~Cy!susIN4>o6Y#LZp<1nMKd*NH9y0gL4@Enx%TS? zeUVf~yE+o-$m!EkmojeUU|t>>$tq7ch*1|#=8jiNoz;^f~PiDZ~^-UqqOWe2>!1@-4}&af#o9T$iQ=2{kdn+A7T@>+ji1`A_18qJ=Kn zwO%PJXz4_`uTRqad6te6)`l{{iu%!sy7IB$vK>!K@aW~|Ng)wdI~tO)15@xJu3hN8 zcuBNfc8+&iYGs}|oh}|TrkRSrzwAXd)S#31kj83~vY&zPV`w+uK8p~io;!7Dy{DEv z^V-!%P*i+mgW-+tsIyqG9s zXU{A0e;rQZf7jvu`}6-ZB=FyL`tLgZpCHEnulLD6ggX0Zn$pnV$ui4HiL_{m(dRSh zvuD0&_#P+k?@kz~ucWVD?Xd2izq+|hs4}8oEh|!5w?ZO$^{y|w>t?-G{M~MN?aB4v zuAodLjRJWZrZ9(9G`RHA2l!&jW*N%;GfGQ+uL*FJ)ils$c9~77# zus6g@d6gX8IHjhh0DnY6D0M_{I6TG1*RP$zh@0ZK^5|*!w=)hNZ*|!Mo&+q6f;lS-86;Dj-CI->(jgY)l6ggkcx;gdO{NbSllDF zxo*NQ>rFEH8*L>DWc{6`yHl}W(r1zAFi#^!^eWngeTx}Yu@H2a!0m%4eL8HQ5Z1K$ zoa>I9l=gx7EwURqH)jjZ7Rw}9?T7-N@Zr2w>MEC7+K&#OCDRgLSA9!4kfG|;xph)3 zjf$Du=g390NG^K_D@g=xzRjzIeK*ZIz@3$amBno#;4tA|lNHLIDmEMPT;s3stZ*o( zW_>AdOiz5`eth=rtbp@6Oow(qX5ei+2HCb<4%>;7^+aWfm1ySai|@rs{CSEIYF<~} z*yjf@CoCIkjY8EP#jfT*PTgKgaS%<9V<@<<+8?#nTp`MBdK_+`&QN^s_D(y;x6Ym3 znrTxaRv9&1_F6u+cVa5$hJo+FyhF%-mkl)ynxhh?*2VavI(nNh`P*_^q^zh5yTg^X_iy(1?oCn}vaPfFlx7-^gl)Sb z)6FNY8+fo?kKxtR*_sRa&QKsIKZ%x%4YxiZQui&*SqXJQH1e4ElIzUlnW+;psx7la z`nqD>O6IJ#>t3`8_-15(<@fk>9WH$5p@BHw{`o@-GG-#XFPR^X;+HBa8;Sm0!!yrO zSsDab$X6t24>Fiwe2SeVd^p3?m$)*~8uArRlj5b+b7dyl%|jDESW@MdH=Nh{yowt_ zl{~s42Mt+qszu3TkuxTr#_>Eb;II`gc1XfT@%J&9WK6X_Oyt!ITzq)aP=NR%(}>d2 zk?W=2AgSv7C*)7H2OR?jp2RqJy><9Ful*oJUMYrYGWu7m`zgD6BaTl@4XeL|=+n1I z03Yv;Zk>w3$(|y-?k6@x#*p@c(@`I4-H*4#bZ?e?mO|&BsBZ9Fm)FkmtlWraSi1 z`k+u|Cc4jpEtMM%$OHLcnHRs@EIztk;+Tk)kd1yCkeO!@G_n@yawK-Vd5gk={q_S| zIO5meiD$feNTXYxv3aWu&@*wMm0V}2MG`9u1q&+`YyM|s$;?w*>$w1pnnM zffoSMYAyLm4Nzb-p8aXaOuuydJ}KTJqE zt`aNMynXAud93q|q46DG$+DX%KT2iVc3fGjI*E{#wkIVr;v{6L`YfX`RJJKkgd3O<6=m$ViDfc# zwkqPL&~?2_jZ^pD2n8|b)jIoqYc*NxpPZpBY}|`?3?yPcYRuFdDxrJj*jV<-ZuHSy zvVh-Zq}vwpMCOr{T!O^Oq|=Y62?1z zsOz%ye1}coHbQ;%K&HZ_>3mh^j7!|4UYpmMksui#&bt9##k-ON*jT%FH-hhc*I}CC zmwAvy^S1f^8rmQBsyBLGx?uCO6O^dvsg2~t%C(8`Pz5G^FS(7nJIRXtGJhFW9w;yg zf9310or>V{3BTS*f624#$M$peJlj7@k}2(1)$H=_OeQ{zSxgvL^IDCjU8;uL z<6yRNo9II4kzk&LD4%yFRcdcY#5+N56JH*q`mj~r=vtkRx(64h?lHxEX;?_(k8(Nc zM~bp^<=bqSMX*}G!UP5V@w^LdBoWmVY{PUzC1^eq71X(nD3r&Lcf|T*KSx+|?~Te` zTUK&knR4q3kGR_eci47NL9nI6E9T&dw(3yWuFwSZreOnle-nFBFJ|)gG5ocox??Fz z4&yT0JkPoEi+3BBRU6(TdNbuh7xgF8U+zAdKS1@fqogP39TE`tWV_s5Sx#;Un8Kgy_n(XdjCEY@2nRd8Uoc~|rb}yuXY=@= zvdF!;$X_>Lj_A73o+Q(bs+l$!+GuL4a4)9D?#K2j2QM|L)w`Ss_Zr!MaV4i>D{30Q zvKO8_dbHOYvR8~d=Oce}Fd1A_{*u)k=Zmq^&*%_#gSd&1B{#~m=wXTlc z?k}rmwi*0&xXc_Gqt`3J0p^1zCei+^GRD_z+ZIpP9qY{6nyCfHNQl0>$Ao`fdBT{E ziqc0=6)L1T{_pfaJFY~W{;_O`N`cmLeOQnUQ*8P}^N}Xn-M=ILgz;al+dyI%Cvwj| z&gh9?O*nLQ*oUZ2Uh+=&$;uU_*T9qfC~Zo39O#DbYg|HVy;PKkb(-gU$yX}p?Y=Ke7$Vr=?bYXM5q zC@B{IWItMC!f3$`V4#-p)I7)`6TbeX;E-yZCdZrCiI zq5LC^d(mWd!0%Rk4UPmS@`JSSd+Dmlh4aq{H5%-c0PxV>e48n0NOl*1<8}cJ0 zw2wTIn&rDUJc3ys1y2faDc$_t&XE_?vqVhzXD)l`rLcnn`kSyaVI zLum2K(?zEnmzuQjc_WA;zjeez5nP?GRf;?`n~FV!G7c;8_-q>qJjG>Gr6)_9D>D4F6QLnaq;rPj7Vq2>1p>OKl6f!hYi$weEwDaG<} z&%wJudb9@Y6s)0U-vqC3%7r=}CZZV9_s)Fqyiattw$UYI!6IUpE5@eGc8+o_XY=!P zQe7RAX1Q*D@lIS!XCS3_ehB;1#y#YNCO3cXks0*-yVv3lDK|23!2mmmTC8uvjpZKv z@_1e62SWjmD6XzWhW;#X6s^U1d%dH3wPzfVFN1<)(&Iu%La>*Lk{YOV3y@wf({;Ny z+HlbAS$D>q8zxcD7c-QDMqDug)1KMv*Vk_G3m-8;^L-#{5z%z@r(GhKsKKbmycFRS z$yetGlf%s`4Y|%1hs-=w>}C^cW&rxY6>j)~#h%N7_@UcGLipLJu@GKp6U?mza#pZm zG(!i4k8pA_J{OB5cVlwhda4h2rbj{)N)KpvlcWZYr)+2IU{MsYA0+hDt9uW=n(A?Wc`fp^k^0Kzk)yNU>ig8#<`*dkdcBV6Hpm>ysWj@1CT=X(93C$# zz5dbJ^!jmN&M+5Dm#1O2nm3FE+hK?inB3OV}}&S3K6) zLli<29lNg{hopGnnqlWxVvT{YB}R3yt$Sx@w096{&&I94r&Ev2;SSCSS>ABG$Wb4d zl8CwBo6g%L7yATR=$McgJt2D9hYjj_*s)G+Ou(@hip!6y>STYW&gZEH`q0|t6ywuy zOOzaiO6VFG`TK6wJfw*l!)HMUC@?vEK&?yE$CNwOu-1n~tEtzFN=?fe)iYc<#)+Qe zAGADt|D;@ogyLUC<0jmTtMPyNPfA#*08ctVF&@xn&6IWUV{Zn4l}DFFbMxxcX@TZx z?z%fPOH%9;mN#7sDkN{8Ua-J7x<^nE1zse3IWQu=%sO@NPuivi>lZ~-_8P~zYvlG6 z0Zp>shVT4rL)9|kZQpFX1X_|-x11d%*o~YXhVt+p;2i9`YZ-gO4^XI@$7@CMt^T^2T&w%0@^N6$yCl}A-IE4`Ja8JW z>V7`=6g@D}4Y-x{$|xP}Z{gr(Uf}?ee#6Y|Z#G9p^$Q38 z$;2{t{B833Ayw?$^;HCiKRJ%#<3S-HEUAi7cQYPyJoLxWfM5OIJfGARQ9a344 zORw?eW_a1Ripp*3Wv`khs2W5WikA^mn>;VlIX3M*v!Yg&4cYPH)Vfc#_ZXU6@5Q35 z%97>mivK=x)IBzjbNN}IZ2e2sl4<-M(+np0V})R`6fyiy(2`9@|yRuxp+F8e(BINE@ zZwF{wzB!IjxHMAbvwd*cw-1gDW@J5VniQe2QYb;6zW3T1MH7n^m;ENPRDoxG+AKzB zhyOOug*mGG;Szt;Ct5RE?TY8n0J%>hG#u9&CDAQHzmFbgpW`iP5ec?WL2l}I>0q$m zk=C@Wl21I}j=MQ{M`egvTAMQzAlzE%uK}u|ca&s8b%6%Qdni8+#vSsE|g##jG8|sN{k7YC8&T*i&X-fC4mvNTOU)P@#JwhJF zH7C{o^pd+jjHPY?!L^ z$DdmV6w0hrq3h)JOZG^u=EzCchy(m6I@s6f5()*??0D`tB|kjTysclc{~L`ZJ7Nj( zFtLBW*u`T~#rm`@ZJq&Shb+lHsr~?=qj$%(e@N}@r71{UhuXf%c$u%IIMuH*WO(x- zBvk4>=TaKgDMXpuVo0yOGpwGipBBj=^2`X~jZMsb^_X|$hzwdRUwW;uaHkvI>`SgE zO)Mp=haj{m!w_*IIHhbp+DAhZ+@pTSlhN(GKGWzEK|OUX6)zDQ02DQeo$z~SzFPXr z(mV{Q`om%_UDm`3^05gPTPM=k_;R0ycN z70TZI)D>eQ_f^^5X3i=we0nSjt}TvP(`J6gHpA)0|5#ucQi*!ma5@*SYhU_peA-cx zLO1Kwmsj5{pIk$>rA^0GhrSoflTd2DXNeM)CCVKeWJSS-PMR&1iCo+7i?rW9BZGgD zzLI-fY$hU0yx4gt8n_lrgWa#`Jw+R);HBD_6v`2Vn3DM_9B;0HMtKYY|Y_@yUgxn87?UYQ7z31)es_g2xKnf?3kWP{*d3dD}~ zYEy9nuZeCSR^jUIs+4$Y>0Qukte8ZhbducVr7tl3-d({p-j;S}3E5P5jU~%-Ctssl z;km@9WI4(65G%%$9OHU3fuvy29z;5e>q7V805peT|iZn59w$Mw5KNFCMMv8$)P ze5r4X8-}viOB1q==aCDtHfABW<5fjL5`AoT$9(nC8x1vFK&q;OvEM*0Oq+nr zZRP{qoT=iP&V=++k`>)1%Yr$4eV-q5-bA8*JC0rO%1#~)WoS<@r?Auk79npTV*<2-apMkws_40%JhWDZ87Xgu|%DNtWXc_5?nZHv#I|jr{U)5iJ zu5({p>$Wa)-KPTiQ3fjEhu!V2DH^d}w}C+|1_#xZDK=5-S;bm!8r`S$*B|qRPah@o z1Gz@h|C4~&ft1k)><5#?kD6l-6QND3ufp103pr)k%@ zrPXMkQ4!FmS*saA>u{1zE^9jz89Lsr@{>4;=jToW8k;YynU2OURY0p&ii3dXi`X%+aJy|En#`(%kD4M_8CIo&b|2^a{hh& zb%4pa)h~j3u(>t;$89(OOmU`m<T5gwo8809Od2E{?1T*S$gjlz>%71;maYHx%Yaj z{yK3j4mM#!Hl8o?S*|;FFg3ss1u7OJNP(Z^wo9!U>=|V6n&k&}9p@Tezp$M!zrYcc z<^BhUc&L4QGW`(&8W}m99*ph`blA`uu4vya=0wiKZMzK6b1Rmhg(g1sjU8T^MdNtt*{rBtR~#~7qm!X3+ICznB4@gO)Z#PX zti%roPDEaQ+c0&{P@pkGstY$?F7PJ(+e$QQ--CZwHs%SxG=a1ub>(wWkTxlGFW-2r zeC$vWvHggyaZW)p=$j?-(VxmJ<|O{u;65u1k;-3Wx!)K0Kam)r&I|?gG!}@T7ni9U z*7C|;7BNgn{M|6Pw_8BX08NV^qZ?=FT}T)K&N+8JtRENP@LSP-LxH?iF795jgvw_O zN!I7SK`2poO_hEbBg-YZpG^l;mrukc9I+MxCY|7k*d%j``h{e@vU|AeiVQ}p7y?L# zF%j+A)t{T*PI6c4vSG{`ZS7ZZ z@A%GgP#R40F-xpQdC`FEm1d+S_+v;}oy^}`s84N>S-lj3Ce1#T7rpRg^Y64uvdF;i zZNvpi2}@0{&P1{z-K!Z;kH*eRFgVJ_mEO%9u0_sIJ*krtAJZMz<{VSqiCPm%wD(iy z8qgOQD`oF}XQaMNrS$%wN^|MMgZfNh8)ad@)d8g!$n@q8QEj1Wr~tYJ8QSwRIm7HZ z2RVDT%3dO%|{8&}P}4pJh@Qa@lXvrwSXn z&5mPcOuR~vl>CEgbMAwuGrUE5{DlOM8ks-(ZWO=)ni7|K({yO^^~z(f%B5Hl-Fxb* z?~i)+dV<B9Vtf5&!>Gfh|B-3{()*oQCc3?}DP7ljsjQWni74EQr9z;| zYAv|TzDur*yJE>TH-?-r^!A-lNdL4E05DN=t$yvzvFySAY?kzN3;gd zrQ^PfEUi%f(ewGm@3u_Ro)pqk^;fG!`t7l`T%59w{ZQ^8`BJKPd)XDs{kL~UqMCx} z#dt$bTH5V?-M|f`{@!ur;g}#kYkaam9z)vV{)2SwOIZ-7_roVql_$!`Ua;JgR7NCh z%OCyL*LD3}$+EX|JC434wsrSp1e_!}h118{uFRHRtHfQ&OCyR!N1Rs6F|xbKk3%Bm z_8MM;?(`nvSs*=?-kj03TbduzMlO!Pum!HzKP-Rf^UQLLfaRz^3jSB)c<#;CRDh=dW5<(-Y9Q9P|ukeMR@wvR;qJ7@uMG`g8mvGzg7?DRY>G z)Q?gv^A2+8riCn@16@)xo!-(7GS4CCG#~-LS<{&W@{p(wWC_#SNjVQJ4k~8iLz`t9#huh&) z=zh_B9|+L*Yc~iN_~jL=iN~72uliYuUm+OVUm-&YzmSJrD)S9QSH{QGYYqc{V83J@ zk^rS5e3Ym{9xFHb+Cb8Tff|B&i=$O{yfl+H=i{=Ri(tj?rfArNPfsxSLk%9c0_%ckv!6N zqEj=wTq}A?_t^3q&=!Q1g+@k-on6&08&mOFzEFf~7n(lXoBf1ujYUW*$a%B7I_{%x zP>91d+nR?00=~#Mpn(X)#67oBzrB1+5xDx~o`ae3Q?S@X=LRb8cf`Jb_}jQb)S@N) zu@#Ef)FauVr2AdV{Krb=52OId0!T8u%8Q)QB_eaW%_n$5{6*ol5f%r* z6Vre_^Wi1btL4;7T}h#0ZM)XTB*|DRIku^S@rEDRm%9g^HG2-b<2c7&7k=gjF6)%z zUOi&|5uLpR%Pd@!&`Z;ZZ!2yq<{^a!hYSc3|9NjCKAhpqdXpe-qU8dTUKZ9RA`uwt(eo}l`Ve;gkKS}Shd zm|bBvd4cn4j_FX~gp*qOH8CSsQ;>N!*g`%4c=T)5`IC7>Gt5y>i|VwUKUlaI$S>y< z9lgPlSN8dJ%M~rtxdU-4`@jvdqV;4ULU%8rJ5;#Pi4{jKm_PG=c)D1|cHikh0;I=! z>)L69z_k|lYt*`@0j}JsEvXhwH!$+as{FihQ@-w<7b=@8BVzhTNhwO<+bGkxWq__z zQplU6FQr!-C9wOn!|YcDGyj%Jp6g?2sqh4(-IA!J>Q5J8VOt*tb}Sg&${2M;PK5e) z8&>~YSHrb~H2{CWPuRd>6zLuSSrbfjyzbg+*dW^5d^ZCr>9FMUIZO(=fE-wvV+_&eO0{Dr!7)jzkEM7ZJ9wem=#(( zcYux7Hm$2eCt35GQA>CGwL>Qm6j);{Qi!g`jmjJLpGH!&u3o)qEo01|c?wJu(i@sb~#@p*#c(ge%&ERp$pT6=<>1j%)uC4JSGM+# z0@qh=WZ;z#=b|HTSl+Z7P|#{V+sZ#u73orl;}1S zPGL^!Q_0%1dy>LA0Y^|ZK)eG@9`td(oZVOx2O#9ApL*fA67Jtcb(}TsVNOYt+Qf$K z8O5#1CKwRL_yIu=BCx0d8>i@ATl1|V&34R#RVe=kVz2G+?@Jf#2^ap-cbjhR3k2^Y z%WeZmruUBBfy0WAbLFkTW#-Opz(gc6RQrLd(CEyUWAQn;hOk(}z~>t)%Qa0(9&|(F z-dx{H%bcG$9F6txklaT68OLcVMDF^QZ?w()hhGE71ck`w?@y;_;vZYyF;_W0AKe!oNba`$qQ;jJ&`6kDK@`_Z?Yfbmwh3NYi?Y=<|eLlsO;g z3`zmASIdcDiZzE1UfsS?1hx(${SrK132!iWjJTbx^iTR!4RFZOUh3NcLqrF|f{DMa z`^SMI1`3}-=?0;mUhaGi)3=yMu(~BY%A9o+1kY#QQuh#+3>bNE{$+Hx<$lPkU)CPH zI?VAzw~$Fhulfy;>P))%Y-GJ+Y_d4MpGA`k|I6^vi8h?p^Hp&?>@2Q?nm*#bLObMJeV$+mva7_Rb<)DmA2R1+I&HSnnqJ|TcdzAojJ*9t;q zYE+M_LHqMkz@9K&4RUt~zIWyR9#YJTuHokQDNWzvwAUW_eL0^rnsXjtQN*CFq6+w3 zSt)^K_t+<_()Wr=(9?)WEr&$FI|~Pu-p%=H@_pU%2iW(?QsqaOZUN`9tnFLUEplo` zMKtJ;>j-5DNm-k#Bx`;@Yqs2{N1mt+7U}jLuLmv!EoZ|NTmP98^@JQHt9vVW%u#gM zZs;(1s>}pD#|svLOWgg-!qlt!5 zj(4elq#o8b1;&l@5u=kGMDatpD8yq5)0)XdvZYQ|U;o?IZi(c3C+;sFzM;6!FL&CH z_p|>^Yb$JiAv1#vqdn8>K=jaTi3J$sjD)NEch(avZyA%JB@==qH$c%Wk8`ObMJAU!+X5WqJ(` z<)C;eZ6DXcKW|oEaV$t>EJ*+8;)&?vq0xN>W+NI!d|qavPuIKKi&KnJL4=zC+LR5G zz8{wkSH=?XdN48XU-B%Fol2j%+SR#3x@u0>SFvdze;&vv%RWo_smjfbbkHI)CTAae z%iOh}Pau^;y^nXfqv|W^Qt61<0d=T-$5rsCAl)ZHY}d%vZ|dnLd1=KcH?;W4peQZb!>gSB8X5MDkXjSMsM&+;aL(lBS{6?OF4OeACe?6BWU6?AjfcJ=#i&j_M!A!UQX z9Uo39X21SsnfnpFJT$J6!+lLe2$=sSu9kFr+~|Hq9^ae~JL@(e1*_$1%fO6da-Y&T zy)jQs8BrQW2V~m4HGJoo5Rwh<5z;~KlKtR0#Qg1p7@He@nYyeJJ!%B zCxp~SjLc@#G^j6|UM8gL{sW%0tT({*mN4qci{RCJ zd+2eo4(W|Mp449ZzMEh(`O~W-;zrJ})$3Nd`I(XYYlOGJ&a%rMU!`uoUN~1jaC6v> zFbOIUi)DjB&lydPZ2RlmdBIx<+q7TudwW&bnInn-B*B% zrHYa+vc&w)i$^?+#^tDG*2w2A zYoSO3VA|fRCm9kkCQ<3S+M4&&J}fJJ_^cmR_*F*Gwz^%f?*v&&KQNI$|XJQ#g4R++er!wl*-Ot(^Sad%sdDDQcO-Q6deht&bC8EmH1SGj;zE zi*F{Jvld{BmK*o_RpuL00LuC)wOlTO3=Dj3vF@rsF9A0pH3=CG6bu9#iMqabXHR+- zUrX3nYx!qxfDjEUajY%<#0KxQ%<+w}yY$I~poL#^p9PK7cVGr$mFjm2_`qca<~#b- zP@s9LY1gXsN<(Ju5Co_9cP~I%CCdwUs@QzO%UD^(PF0T%GDFmFMm;4En9Nnj$jDzO zp^4C^zW)^ZG_q}<5Zi&TkA#@nf4~2C9sauy{{J}#{`p@>E?r^(GF;_>#S08S*T|Px zun%KOdvE^!Asrq0%MehXXm;wc=361GSWO}@3Ih?OJm2v0OHOOLGK5dJq)zLTQCc-#Y?9kMX#P~ zN(Ci>eQ+bf;Gv}*EzSHBI@iyX5L^Qn6m}m4KJ|=GOGF@oHAB1Qn79r0Y5kw)=u-(1 ztLv|<55R#!lLFku_m16{!Nt5!At)IyCG>Ts?8DYZ+?OJDNNQn!boOSJI7^h!A=ANq z$N|oeTh1e{v>!)7xBwboE6?^aOcjXC>v}Owj+8*kA0}!6g`p07sg6Us(`~F|d*dF# z;t00$L{wu;t0z8UfTAq*&(vu^Kdd1`N{5Wm6;v9p1@M<@c8pIo!IHs_{-ubl^fzSn zr8%y&6>gx1(y9ok@M|YDRHs7IO+lccAPi~~&bKHuTIW2wQ29dT*F?<{Di|h;(`JH^6vEQlC146jlDU7n9fr?%I`Txa{PaA!c zKpcahh!E1mg{@_69CF03nCXJsn$Sw-ZPD20AD6%<6VX3BfIbPyQ6`F+0j^g z2!t@lZk-%~3p+kZcu!ZH0GNw&ZRcoaXGXM8Fe_zE6}wKNrM`03%rQaufNxUUG@YgE zNZv1qy9OU$8D5s>v_mkN0-JFM3=BEejVu}J~c{jf8cGcE#hxm}P3FTvZ7Byr90N6#SJ zoWa#!Z+n>t*g7=OsO?^*Z~sV}4c!L|#1){TTae{clW0QpKyL;Mi`TYW+)_aWOU@&< zOadBJXbt09&fhn2Q(zJ)w2zL|&dr`-(NLjH1v5QZ<(q|NArLDGn-2ht8Ud}T*PfZ! z{nEPud77wBe@?{IdA}c^vg+CeW|Ci&-qVCcYyx^mf|XybV8i@C;`l5P_%gv1V#~~9 z^^_PEh!>9dXN-*!&BFxDp%D0npT83XHw?MuP|(%5FXYsu!fm^(!t)&U*&AwgiI}{{&Op z^a%cG`cjLfyLsXA#(e{it})mV1d9MH)Jem1l`Id+mZ?+Zkh6w`W#$1l`9>leX)Gz4 z5EUVfy;T%^0x`w5i!@^2!wZx4lcMfTnpi zBYZSR$PlGG*`+CQLmbgb=9sRL{<7ZLRDB!OS$Bq-H2)Q6%??xUT(7pJR1{mt_iY5? zlWN#P1JewpVIsSRudF7}`v^=nST=;UCnCk2=VoB%6I)_GSkDVtZcBq==ynRX=tz_z zX@mg#br92mG^Iy%y!=c~pL!m)`hzRrLUR2~uUk$VGDHFlCTOie zmEoXXay?iL45NYEfS6GYAtvPpF6HRpF0}^+$aS$F6kWXDNj())~s1(yeX&xIsMF~-2&@R5MBSf?nfV0g| z6a%jZL{VOU!9zGibAA`WLOGy7L2#$cZAgaS2-l{-qK&{s09?PJK(ItA{%oKzO|$Y& zC7nLFm~cF?=Xs*iLvo?c&qR0fC(4aGk$_`lBk?yc1Y&>zHmdPO2ZAj7aT2}He~uyD z^D-oNLAefc>{8tKc4Lk98VPG2HwLGTaYf2P8WWTku!^U~s<|)ALM$M`vZ-g(ANgQA;eR$Rn{!;NtFKzl#idn+Lj_;hfjb>34_b?BH!2}mc#hjOk*g{66W8Tj+aa`CTgyhs6*Mfi49wh-&X;3{I09IBP z{_&ff{V#Y;evb05+2S~KJX|#*j0@aS2Xw2UBAx z=y+`O)O!yDxNcZ%dy1yxb8&@ct@V;`JMkjGbkt`aepg(35Dz}Fp{^qt8N3fWM6iI= z2$hhITkV$sL;0CLmb`=6ID%zM0RN{^ zotK1Kt3^~+3Nu@wEf%?)u;04>BO~xWC8)=>^dq}Y(0tkF1f=%oX}aBZYXLWW2-(6z zitzeg_+Hga*TYGZ)ZV~a97nQ&dTnB91&CP=feX^7+RqUcB_Si)miZrH_o9g+6#JuY zq~R=l#@sm(5PoM0z z%nGNymD-53gDuC&a>b5m~GeN^Y2@?T4lvW zmqdkwz2vZ1>u+RNN}MN6&I167241yfuQ3BmSBe!B)wrmxFk6Pl_3P!Og?`Sjz-AHw zEXx?iG7Iu!ckpRtdcJ>?3-0@HA?`KE-S;0AYw>ofJv_{^_H}!f2?>u~wQ()Su5@U% z5osaI<3GZJ$BDqT@NIpcV7($T8DcN?q8JaI*=$dm-Z_s$l7kI82?znca0@U)TmC!D znYydvzKy7+bfwDhnuzhcrpD+FukNE+rVTK97^X{pSOwoWaGbXaR$ul^F!gE8Z47pA zEYNTbze-$c-d5!q%|MOhX~r??XAj&}c|Z$KD@c7GWWPmd{$xeT*7kwzJEhGmjyZr+ z=!+Dno(9?Qj_C$v5Lz`u(ZgL&{O$T9&I5@tRS=IyNL_Qm;osx7x<}}uK<;bno_1<8 z{RnGXAak@uS{t)hD7$*0elX}fs>5bJ0Uo7bk22SB#;ui$rrZ7*&H`5bisicIveKzO zme!hH(2$@y zXaX%;asIx=4e>$F*Y9eTp@gXk+X%C(wlJ}6KrqLJoZ$9lLg7v8LY?>?=YgPhwUkTt zIj>1r`ciYJ57We!xdP(e;o_Bdd-~GHxGDNf9nLY+pm#y^stGIpr*uC%{F(o-WvMD( z7h(0=10Ll8fGYR2=ItpaY!0a#BWYR#U=+iTY+cq^9HB#lnF+R;`C)~7Fs2%*5sQ$lw?41!V&#qVu; zAfP#>@>cNt$+s4EfrIhrBRWvdpu3+ONbg(?k&6TA08S8Sd*@Iw8S9<&KU1*$v0Oj` zd*^pi^eM|DA<$_HoitUcGWQSFLuQC()SGd3HZp%PdBdLdO!-#y!eOQENjY{XG?AXT!B>c zvbpo@QpY(x5JsT&g%(}*dsZ!J6bphSA$U;i%z+T{K(EyK>CCtq$;%W~LW|t|=`V9-+g*-g~NU<$Uk(qQL7x zzmuPJ5|wRfw*f>;faL7($dB`1YeZr0dOaNI%J&HYk;1;}7eUS}DeQ;#RE4G=;8+Y6_&nh(GPAF$+l;KUDK z9n1$M>NQJ2d#>63Y&);1d;zdN@PSaGq5qX#d55o@b_0b$???*VyE!2^5J7i<3vUjnDF?3HZ@~Y0*I%&pT`L4q~*?E|*5N3yz%|rg%Wi0@TSnwxN#MfnE}b z-#X~sIqMrJlelR+p|WM)l<(Rc=XOd1CRH*}MSgQu&nvqa3r6oD#@VA7-dix!S8AiGBDT<%q3*|GV)z>L9Deo_>jw0 z;!Fpd{Dy@e%SL)gi*PU@=M0+x5(T_~K-!C=@pP!p1ZV7C0gDbZBEoIU{!iK=XR$UN zGkNRviROw*(2>CLr82nqu0VFx1&Gd1%#uW?4no~S<6<&zG+IM4G{PT(akEfOIw2S z ztc#cG%3uA|6^{uj-VPdiX50gV;LPK$h~d25#?TYpZx6{e0%mqT1m+imv=qC8 zb$HOg?w>9~;t1B)8nk{0)D)O|umJ<2I%ZV+YWP1Mc2^eQ<9pNjBLwJ9bni}%sINAH zW|c~IRTr$nW|6LVQax6KA&Akf-fq+WN&MQe%Q%r4tM;gFs|D)k zSr`x;bsM7qo3kE>`8NZMk%Ie>0v;BE0TIOXyV4wZG8kkD|4jxS$*tTbPQ6BFJBuxM zX`}I{GiZWXy6rcH$;{svT{8gLwfWgypnEW#iBChDf{5V_pa=R0A|yl~DrMFZ@>0vq zXZ2lh>{TArM;G9sMO-LL8F7efZ_5VrgP?u}22n`hPnMYy3`{~a-UNdbM6(#-{)8uD zava;eo4^0ngTeKaf)^VM@NWo{K+Cj5T)?OvqOkPtOB>kn?gC{=! zT=8H02~y6~d%09}g`}DZo$3<8 z1~}__InGu}2-OMp^pAeZIshSImg%luObF9`t-H((@-aB|3FQnDk4f5H7f!V&Dc9%0 z(-UmjljB^ox0*QemQ#}Z+&X<9HR5%EGl&@Mg9k8dpa>@uZF>ryvysC7!LyWbBB6~~ zfOHZ%V`74#?f7eXE6yA-gwgfHD--yfxCl~E{v ziAKZeWr2AHT=a2OqZ^hCiaA)1tN%|mXZqCS8AWlZg9;WI77+`SRTxNx3bKQ?5Q<45 zh!q%FiY$ZDDgg(@fPsnv0tJRrib0LCG@vYQEztx^SrY*p2OPyhSR|ALLfmM?1fl2k zzv!oYdNN5S?|tq)_jgWtt|@A4fxf0_DUg*tn>+fB`v!4(=z5^0>cRr41*mxb*B4#z zTVV-}i)AS=g*nw~!YSvub_S1~efsl2&5($XObkK`{W`$9f6%Z+uvF0Gsc1!yX-=Y_ zd*7I`%})0)^Q>edd#`+DP;;=^ciJ3-cAsgA9n{=?Vp)>f{SjXo0qnT%`FcCWiIxsc zvqBhH!eR^=I`T{nk&Y<8oY{;qnvc zBlztd3G6s#ay=L_Du@{m6r3XZnmK-^)V-n|obq!~ z#zk|NPObIp$N#ZVB&t}54ML(x9p*o@vy{6s|9KIwN_c7OBS@2>DX}Gqr|gX-2yp~u zSPcu|j*xMrn3(bZWb@nV=wj|Q?`hsnd$-M0xrzjjN5&q8D<(|1ayFt5LoJX3_y7gx z+Z8oWgJ|E_=~e4(;dsp9GnY4L1BGEWSH|<5T@Le_cW(b6+T1DJBON8amUl$XMwCr( zj+Ssge$lShMurYAEHe5+a35P)l&kwe#8%mX&fJVx4%m+Y?c1V`AvUmNrcBSErDR%p zV>I6geFCLv)hro;1JM#8&v3vv<{@Tu%4EMq^)E?23Gxo?lZVp4Em;SZK6i>K9f>V< zs3<>X`sVN9MbcjG4bN^jM}?((g_Ul=_P67uNaf&^q&7l2wv8_4PXs2z*-h`cKyv$- zlsM%EU_D!LuLk@`J)ZuT%LBqb_mNjpbvrz7P5B7y7?902-kD_F|Jb7Tz@6_;e{X$Ci5edg^L+iz=TKY_=bZohj(mXls zNK+7CV=$3s6jbGZTPyUcF-)W4a8?G*ig_TPA^0mk__eSdvK|a3&KNt>TXy03xi##n zzH*%}hnU;6zkjDJP;hLP(d#z})-tDmA8nLf|q^tcD)h?v+ z30czgQ)+I;l<{pfT25mDH8j(WddZ)9zj|g`>Sw@`J?$v7661{&0vDDS~`T q Date: Wed, 3 Jun 2026 13:10:16 +0530 Subject: [PATCH 12/29] Remove v2 prefix from test endpoint URLs for collections and texts --- .../test_collections_openpecha_views.py | 20 +++---- .../test_segments_openpecha_service.py | 2 + tests/texts/test_texts_openpecha_views.py | 60 +++++++++---------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/tests/collection/test_collections_openpecha_views.py b/tests/collection/test_collections_openpecha_views.py index 379e3150c..8b118730e 100644 --- a/tests/collection/test_collections_openpecha_views.py +++ b/tests/collection/test_collections_openpecha_views.py @@ -38,7 +38,7 @@ def test_get_collections_success(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/collections?language=en") + response = client.get("/collections?language=en") assert response.status_code == 200 data = response.json() @@ -73,7 +73,7 @@ def test_get_collections_with_parent_id(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/collections?parent_id=parent-1&language=en") + response = client.get("/collections?parent_id=parent-1&language=en") assert response.status_code == 200 data = response.json() @@ -99,7 +99,7 @@ def test_get_collections_with_pagination(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/collections?skip=10&limit=5") + response = client.get("/collections?skip=10&limit=5") assert response.status_code == 200 data = response.json() @@ -116,7 +116,7 @@ def test_get_collections_empty(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/collections") + response = client.get("/collections") assert response.status_code == 200 data = response.json() @@ -142,7 +142,7 @@ def test_get_collections_tibetan_language(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/collections?language=bo") + response = client.get("/collections?language=bo") assert response.status_code == 200 data = response.json() @@ -152,15 +152,15 @@ def test_get_collections_tibetan_language(self, mock_service): class TestCollectionsV2ValidationErrors: def test_invalid_skip_negative(self): - response = client.get("/v2/collections?skip=-1") + response = client.get("/collections?skip=-1") assert response.status_code == 422 def test_invalid_limit_zero(self): - response = client.get("/v2/collections?limit=0") + response = client.get("/collections?limit=0") assert response.status_code == 422 def test_invalid_limit_too_large(self): - response = client.get("/v2/collections?limit=101") + response = client.get("/collections?limit=101") assert response.status_code == 422 @@ -174,7 +174,7 @@ def test_get_collections_upstream_error(self, mock_service): detail="Failed to fetch collections from upstream service" ) - response = client.get("/v2/collections") + response = client.get("/collections") assert response.status_code == 502 assert "upstream" in response.json()["detail"].lower() @@ -188,6 +188,6 @@ def test_get_collections_internal_error(self, mock_service): detail="Internal server error" ) - response = client.get("/v2/collections") + response = client.get("/collections") assert response.status_code == 500 diff --git a/tests/segments/test_segments_openpecha_service.py b/tests/segments/test_segments_openpecha_service.py index 4fd2d161a..821923eb2 100644 --- a/tests/segments/test_segments_openpecha_service.py +++ b/tests/segments/test_segments_openpecha_service.py @@ -5,7 +5,9 @@ from pecha_api.texts.segments.segments_openpecha_service import ( _classify_text, _fetch_segment_content_safe, + get_openpecha_segment_details_by_id, get_translations_by_segment_id_from_openpecha, + get_commentaries_by_segment_id_from_openpecha, ) from pecha_api.texts.segments.segments_response_models import ( V2SegmentCommentariesResponse, diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py index 366679bb3..11549e893 100644 --- a/tests/texts/test_texts_openpecha_views.py +++ b/tests/texts/test_texts_openpecha_views.py @@ -91,7 +91,7 @@ def test_get_texts_by_collection_success(self, mock_service): limit=10, ) - response = client.get("/v2/texts/collection/cat-1") + response = client.get("/texts/collection/cat-1") assert response.status_code == 200 data = response.json() @@ -107,7 +107,7 @@ def test_get_text_by_id_success(self, mock_service): license="CC0", ) - response = client.get("/v2/texts/t1") + response = client.get("/texts/t1") assert response.status_code == 200 data = response.json() @@ -117,11 +117,11 @@ def test_get_text_by_id_success(self, mock_service): class TestTextsV2ValidationErrors: def test_invalid_skip_negative(self): - response = client.get("/v2/texts/collection/cat-1?skip=-1") + response = client.get("/texts/collection/cat-1?skip=-1") assert response.status_code == 422 def test_invalid_limit_zero(self): - response = client.get("/v2/texts/collection/cat-1?limit=0") + response = client.get("/texts/collection/cat-1?limit=0") assert response.status_code == 422 @@ -133,7 +133,7 @@ def test_get_texts_upstream_error(self, mock_service): detail="Failed to fetch texts from upstream service", ) - response = client.get("/v2/texts/collection/cat-1") + response = client.get("/texts/collection/cat-1") assert response.status_code == 502 assert "upstream" in response.json()["detail"].lower() @@ -145,7 +145,7 @@ def test_get_text_by_id_not_found(self, mock_service): detail="Text with id 'missing' not found", ) - response = client.get("/v2/texts/missing") + response = client.get("/texts/missing") assert response.status_code == 404 @@ -165,7 +165,7 @@ def test_get_text_versions_success(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions") + response = client.get("/texts/text-123/versions") assert response.status_code == 200 data = response.json() @@ -191,7 +191,7 @@ def test_get_text_versions_with_language_filter(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions?language=en") + response = client.get("/texts/text-123/versions?language=en") assert response.status_code == 200 data = response.json() @@ -212,7 +212,7 @@ def test_get_text_versions_with_pagination(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions?skip=1&limit=5") + response = client.get("/texts/text-123/versions?skip=1&limit=5") assert response.status_code == 200 data = response.json() @@ -232,7 +232,7 @@ def test_get_text_versions_empty_versions(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions") + response = client.get("/texts/text-123/versions") assert response.status_code == 200 data = response.json() @@ -247,7 +247,7 @@ def test_get_text_versions_with_all_parameters(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions?language=bo&skip=2&limit=20") + response = client.get("/texts/text-123/versions?language=bo&skip=2&limit=20") assert response.status_code == 200 mock_service.assert_called_once_with( @@ -268,7 +268,7 @@ def test_get_text_versions_not_found(self, mock_service): detail="Text with id 'nonexistent' not found" ) - response = client.get("/v2/texts/nonexistent/versions") + response = client.get("/texts/nonexistent/versions") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @@ -280,7 +280,7 @@ def test_get_text_versions_upstream_error(self, mock_service): detail="Failed to fetch text from external API" ) - response = client.get("/v2/texts/text-123/versions") + response = client.get("/texts/text-123/versions") assert response.status_code == 502 assert "external" in response.json()["detail"].lower() or "failed" in response.json()["detail"].lower() @@ -292,7 +292,7 @@ def test_get_text_versions_internal_error(self, mock_service): detail="Internal server error" ) - response = client.get("/v2/texts/text-123/versions") + response = client.get("/texts/text-123/versions") assert response.status_code == 500 @@ -308,7 +308,7 @@ def test_response_contains_all_text_fields(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions") + response = client.get("/texts/text-123/versions") assert response.status_code == 200 text_data = response.json()["text"] @@ -328,7 +328,7 @@ def test_response_contains_all_version_fields(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions") + response = client.get("/texts/text-123/versions") assert response.status_code == 200 version_data = response.json()["versions"][0] @@ -365,7 +365,7 @@ def test_response_with_optional_fields_none(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/v2/texts/text-123/versions") + response = client.get("/texts/text-123/versions") assert response.status_code == 200 version_data = response.json()["versions"][0] @@ -405,7 +405,7 @@ def test_get_text_commentaries_success(self, mock_service): ) mock_service.return_value = [mock_commentary] - response = client.get("/v2/texts/text-123/commentaries") + response = client.get("/texts/text-123/commentaries") assert response.status_code == 200 data = response.json() @@ -443,7 +443,7 @@ def test_get_text_commentaries_multiple(self, mock_service): ] mock_service.return_value = mock_commentaries - response = client.get("/v2/texts/text-123/commentaries") + response = client.get("/texts/text-123/commentaries") assert response.status_code == 200 data = response.json() @@ -456,7 +456,7 @@ def test_get_text_commentaries_multiple(self, mock_service): def test_get_text_commentaries_empty(self, mock_service): mock_service.return_value = [] - response = client.get("/v2/texts/text-123/commentaries") + response = client.get("/texts/text-123/commentaries") assert response.status_code == 200 data = response.json() @@ -466,7 +466,7 @@ def test_get_text_commentaries_empty(self, mock_service): def test_get_text_commentaries_with_pagination(self, mock_service): mock_service.return_value = [] - response = client.get("/v2/texts/text-123/commentaries?skip=5&limit=20") + response = client.get("/texts/text-123/commentaries?skip=5&limit=20") assert response.status_code == 200 mock_service.assert_called_once_with( @@ -479,7 +479,7 @@ def test_get_text_commentaries_with_pagination(self, mock_service): def test_get_text_commentaries_with_skip_only(self, mock_service): mock_service.return_value = [] - response = client.get("/v2/texts/text-123/commentaries?skip=10") + response = client.get("/texts/text-123/commentaries?skip=10") assert response.status_code == 200 mock_service.assert_called_once_with( @@ -492,7 +492,7 @@ def test_get_text_commentaries_with_skip_only(self, mock_service): def test_get_text_commentaries_with_limit_only(self, mock_service): mock_service.return_value = [] - response = client.get("/v2/texts/text-123/commentaries?limit=50") + response = client.get("/texts/text-123/commentaries?limit=50") assert response.status_code == 200 mock_service.assert_called_once_with( @@ -502,11 +502,11 @@ def test_get_text_commentaries_with_limit_only(self, mock_service): ) def test_get_text_commentaries_invalid_skip(self): - response = client.get("/v2/texts/text-123/commentaries?skip=-1") + response = client.get("/texts/text-123/commentaries?skip=-1") assert response.status_code == 422 def test_get_text_commentaries_invalid_limit(self): - response = client.get("/v2/texts/text-123/commentaries?limit=101") + response = client.get("/texts/text-123/commentaries?limit=101") assert response.status_code == 422 @@ -520,7 +520,7 @@ def test_get_text_commentaries_not_found(self, mock_service): detail="Text with id 'nonexistent' not found" ) - response = client.get("/v2/texts/nonexistent/commentaries") + response = client.get("/texts/nonexistent/commentaries") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @@ -532,7 +532,7 @@ def test_get_text_commentaries_upstream_error(self, mock_service): detail="Failed to fetch commentaries from external API" ) - response = client.get("/v2/texts/text-123/commentaries") + response = client.get("/texts/text-123/commentaries") assert response.status_code == 502 @@ -543,7 +543,7 @@ def test_get_text_commentaries_internal_error(self, mock_service): detail="Internal server error" ) - response = client.get("/v2/texts/text-123/commentaries") + response = client.get("/texts/text-123/commentaries") assert response.status_code == 500 @@ -573,7 +573,7 @@ def test_commentary_contains_all_required_fields(self, mock_service): ) mock_service.return_value = [mock_commentary] - response = client.get("/v2/texts/text-123/commentaries") + response = client.get("/texts/text-123/commentaries") assert response.status_code == 200 data = response.json()[0] @@ -616,7 +616,7 @@ def test_commentary_with_optional_fields_none(self, mock_service): ) mock_service.return_value = [mock_commentary] - response = client.get("/v2/texts/text-123/commentaries") + response = client.get("/texts/text-123/commentaries") assert response.status_code == 200 data = response.json()[0] From 43e7aaccf9db1261ec7a2cb6412e3498948e80d6 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Wed, 3 Jun 2026 13:17:17 +0530 Subject: [PATCH 13/29] test update --- tests/texts/test_texts_openpecha_services.py | 14 ++++++++------ tests/texts/test_texts_openpecha_views.py | 8 ++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/texts/test_texts_openpecha_services.py b/tests/texts/test_texts_openpecha_services.py index a28296dbc..64e3f7c94 100644 --- a/tests/texts/test_texts_openpecha_services.py +++ b/tests/texts/test_texts_openpecha_services.py @@ -6,6 +6,7 @@ from pecha_api.texts.texts_openpecha_service import get_text_detail_by_id, trim_segment_content from pecha_api.texts.text_openpecha_response_models import ( TextDetailResponse, + TextDetailWithContentResponse, CriticalEditionModel, ContributionModel, SegmentationResponseModel, @@ -87,12 +88,13 @@ async def test_get_text_detail_by_id_success(mocker): result = await get_text_detail_by_id(text_id=TEXT_ID, offset=0, limit=30) - assert result.id == TEXT_ID - assert result.edition_details == MOCK_EDITIONS - assert len(result.segments.contents) == 2 - assert result.segments.has_more is False - assert result.segments.offset == 0 - assert result.segments.limit == 30 + assert isinstance(result, TextDetailWithContentResponse) + assert result.text_detail.id == TEXT_ID + assert result.text_detail.title == "Test Text" + assert result.size == 2 + assert result.current_segment_position == 1 + assert len(result.content.sections) == 1 + assert len(result.content.sections[0].segments) == 2 @pytest.mark.asyncio diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py index 11549e893..3a2a3936a 100644 --- a/tests/texts/test_texts_openpecha_views.py +++ b/tests/texts/test_texts_openpecha_views.py @@ -91,7 +91,7 @@ def test_get_texts_by_collection_success(self, mock_service): limit=10, ) - response = client.get("/texts/collection/cat-1") + response = client.get("/texts?collection_id=cat-1") assert response.status_code == 200 data = response.json() @@ -117,11 +117,11 @@ def test_get_text_by_id_success(self, mock_service): class TestTextsV2ValidationErrors: def test_invalid_skip_negative(self): - response = client.get("/texts/collection/cat-1?skip=-1") + response = client.get("/texts?collection_id=cat-1&skip=-1") assert response.status_code == 422 def test_invalid_limit_zero(self): - response = client.get("/texts/collection/cat-1?limit=0") + response = client.get("/texts?collection_id=cat-1&limit=0") assert response.status_code == 422 @@ -133,7 +133,7 @@ def test_get_texts_upstream_error(self, mock_service): detail="Failed to fetch texts from upstream service", ) - response = client.get("/texts/collection/cat-1") + response = client.get("/texts?collection_id=cat-1") assert response.status_code == 502 assert "upstream" in response.json()["detail"].lower() From 571495f1c1ed90e7c7b02d4a23e4e8e36d08f8d7 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Wed, 3 Jun 2026 13:29:35 +0530 Subject: [PATCH 14/29] feat(segments): add pagination safety limits and iteration cap to prevent unbounded loops - Added constants for maximum pagination skip (10000), limit (100), and fetch iterations (100) - Sanitized user-controlled skip and limit inputs in `_fetch_matching_related_segments_by_text_id` to prevent unbounded loops - Added iteration counter to break out of fetch loop after MAX_FETCH_ITERATIONS to prevent infinite loops - Applied safe_skip and safe_limit throughout pagination logic for consistent bounds --- .../segments/segments_openpecha_service.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py index 06b398c22..ae3ec6e4b 100644 --- a/pecha_api/texts/segments/segments_openpecha_service.py +++ b/pecha_api/texts/segments/segments_openpecha_service.py @@ -29,6 +29,9 @@ TRANSLATION = "translation" COMMENTARY = "commentary" RELATED_SEGMENTS_MAX_PAGE_SIZE = 100 +MAX_PAGINATION_SKIP = 10000 +MAX_PAGINATION_LIMIT = 100 +MAX_FETCH_ITERATIONS = 100 def _classify_text(text_payload: Dict[str, Any]) -> Optional[str]: @@ -151,12 +154,18 @@ async def _fetch_matching_related_segments_by_text_id( skip: int, limit: int, ) -> Tuple[List[Dict[str, Any]], bool]: + # Sanitize user-controlled inputs to prevent unbounded loops + safe_skip = max(0, min(skip, MAX_PAGINATION_SKIP)) + safe_limit = max(1, min(limit, MAX_PAGINATION_LIMIT)) + matching_items: List[Dict[str, Any]] = [] upstream_offset = 0 upstream_has_more = True - target_count = skip + limit + 1 + target_count = safe_skip + safe_limit + 1 + iterations = 0 - while upstream_has_more and len(matching_items) < target_count: + while upstream_has_more and len(matching_items) < target_count and iterations < MAX_FETCH_ITERATIONS: + iterations += 1 related_page = await fetch_related_segments( segment_id=segment_id, limit=RELATED_SEGMENTS_MAX_PAGE_SIZE, @@ -171,8 +180,8 @@ async def _fetch_matching_related_segments_by_text_id( break upstream_offset += len(items) - paginated_items = matching_items[skip : skip + limit] - has_more = len(matching_items) > skip + limit + paginated_items = matching_items[safe_skip : safe_skip + safe_limit] + has_more = len(matching_items) > safe_skip + safe_limit return paginated_items, has_more From 2bf77b1d8714bb8abb1563cdb1b91510a3a0f038 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Wed, 3 Jun 2026 15:26:19 +0530 Subject: [PATCH 15/29] segment_info_migration_implementation --- .../segments/segments_openpecha_service.py | 53 +++++++++++++++++++ .../segments/segments_openpecha_views.py | 16 ++++++ .../segments/segments_response_models.py | 21 ++++++++ 3 files changed, 90 insertions(+) diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py index ae3ec6e4b..1d5e9684b 100644 --- a/pecha_api/texts/segments/segments_openpecha_service.py +++ b/pecha_api/texts/segments/segments_openpecha_service.py @@ -14,8 +14,12 @@ from .segments_response_models import ( ParentSegment, + SegmentInfo, + SegmentRelatedText, + SegmentResources, V2RelatedSegmentItem, V2SegmentCommentariesResponse, + V2SegmentInfoResponse, V2SegmentResponse, V2SegmentRootTextResponse, V2SegmentTextDetail, @@ -307,3 +311,52 @@ async def get_commentaries_by_segment_id_from_openpecha( limit=limit, has_more=has_more, ) + + +async def get_segment_info_by_id_from_openpecha( + segment_id: str, +) -> V2SegmentInfoResponse: + try: + segment_details = await fetch_segment_details(segment_id) + except Exception: + logger.exception("Failed to fetch segment details for %s", segment_id) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Segment with id '{segment_id}' not found", + ) + + text_id = segment_details.get("text_id") + if not text_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Text ID not found for segment '{segment_id}'", + ) + + text_payload = await _fetch_text_safe(text_id) + if not text_payload: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Text with id '{text_id}' not found", + ) + + translations_count = len(text_payload.get("translations", [])) + commentaries_count = len(text_payload.get("commentaries", [])) + + root_text_count = 0 + if text_payload.get("commentary_of") or text_payload.get("translation_of"): + root_text_count = 1 + + segment_info = SegmentInfo( + segment_id=segment_id, + text_id=text_id, + translations=translations_count, + related_text=SegmentRelatedText( + commentaries=commentaries_count, + root_text=root_text_count, + ), + resources=SegmentResources( + sheets=0, + ), + ) + + return V2SegmentInfoResponse(segment_info=segment_info) diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py index b22dba9a9..1ee40cea4 100644 --- a/pecha_api/texts/segments/segments_openpecha_views.py +++ b/pecha_api/texts/segments/segments_openpecha_views.py @@ -7,10 +7,12 @@ get_commentaries_by_segment_id_from_openpecha, get_openpecha_segment_details_by_id, get_root_text_by_segment_id_from_openpecha, + get_segment_info_by_id_from_openpecha, get_translations_by_segment_id_from_openpecha, ) from .segments_response_models import ( V2SegmentCommentariesResponse, + V2SegmentInfoResponse, V2SegmentResponse, V2SegmentTranslationsResponse, V2SegmentRootTextResponse, @@ -79,3 +81,17 @@ async def get_commentaries_for_segment_v2( skip=skip, limit=limit, ) + + +@segments_v2_router.get( + "/{segment_id}/info", + status_code=status.HTTP_200_OK, + summary="Get Segment Info", + description="Retrieve segment information including translation count, related text counts, and resources.", +) +async def get_segment_info_v2( + segment_id: str, +) -> V2SegmentInfoResponse: + return await get_segment_info_by_id_from_openpecha( + segment_id=segment_id, + ) diff --git a/pecha_api/texts/segments/segments_response_models.py b/pecha_api/texts/segments/segments_response_models.py index 41d81a4cf..4a1d0008c 100644 --- a/pecha_api/texts/segments/segments_response_models.py +++ b/pecha_api/texts/segments/segments_response_models.py @@ -125,3 +125,24 @@ class V2SegmentResponse(BaseModel): segment_id: str content: str text: Optional[V2SegmentTextDetail] = None + + +class SegmentRelatedText(BaseModel): + commentaries: int + root_text: int + + +class SegmentResources(BaseModel): + sheets: int + + +class SegmentInfo(BaseModel): + segment_id: str + text_id: str + translations: int + related_text: SegmentRelatedText + resources: SegmentResources + + +class V2SegmentInfoResponse(BaseModel): + segment_info: SegmentInfo From 45b8094f7d19bb3d6c189ffb3a03901c79996dc0 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Wed, 3 Jun 2026 15:30:46 +0530 Subject: [PATCH 16/29] test update --- .../test_segments_openpecha_service.py | 235 ++++++++++++++++++ .../segments/test_segments_openpecha_views.py | 133 ++++++++++ 2 files changed, 368 insertions(+) diff --git a/tests/segments/test_segments_openpecha_service.py b/tests/segments/test_segments_openpecha_service.py index 821923eb2..bef0dfad4 100644 --- a/tests/segments/test_segments_openpecha_service.py +++ b/tests/segments/test_segments_openpecha_service.py @@ -6,11 +6,13 @@ _classify_text, _fetch_segment_content_safe, get_openpecha_segment_details_by_id, + get_segment_info_by_id_from_openpecha, get_translations_by_segment_id_from_openpecha, get_commentaries_by_segment_id_from_openpecha, ) from pecha_api.texts.segments.segments_response_models import ( V2SegmentCommentariesResponse, + V2SegmentInfoResponse, V2SegmentResponse, V2SegmentRootTextResponse, V2SegmentTranslationsResponse, @@ -562,3 +564,236 @@ async def content_side_effect(segment_id: str): assert len(result.translations) == 1 assert len(result.translations[0].segments) == 1 assert result.translations[0].segments[0].id == "seg-trans-2" + + +class TestGetSegmentInfoByIdFromOpenpecha: + """Tests for the get_segment_info_by_id_from_openpecha service function.""" + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_returns_segment_info_with_translations_and_commentaries( + self, + mock_fetch_segment_details, + mock_fetch_text, + ): + """Test successful retrieval of segment info with translations and commentaries counts.""" + mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID} + mock_fetch_text.return_value = { + "id": ROOT_TEXT_ID, + "title": {"bo": "Root Text Title"}, + "language": "bo", + "translations": ["trans-1", "trans-2", "trans-3"], + "commentaries": ["comm-1", "comm-2"], + "translation_of": None, + "commentary_of": None, + } + + result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID) + + assert isinstance(result, V2SegmentInfoResponse) + assert result.segment_info.segment_id == PARENT_SEGMENT_ID + assert result.segment_info.text_id == ROOT_TEXT_ID + assert result.segment_info.translations == 3 + assert result.segment_info.related_text.commentaries == 2 + assert result.segment_info.related_text.root_text == 0 + assert result.segment_info.resources.sheets == 0 + + mock_fetch_segment_details.assert_awaited_once_with(PARENT_SEGMENT_ID) + mock_fetch_text.assert_awaited_once_with(ROOT_TEXT_ID) + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_returns_root_text_count_1_when_text_is_translation( + self, + mock_fetch_segment_details, + mock_fetch_text, + ): + """Test that root_text count is 1 when the text is a translation of another text.""" + mock_fetch_segment_details.return_value = {"text_id": TRANSLATION_TEXT_ID} + mock_fetch_text.return_value = { + "id": TRANSLATION_TEXT_ID, + "title": {"en": "Translation Title"}, + "language": "en", + "translations": [], + "commentaries": [], + "translation_of": "root-text-id", + "commentary_of": None, + } + + result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID) + + assert result.segment_info.related_text.root_text == 1 + assert result.segment_info.translations == 0 + assert result.segment_info.related_text.commentaries == 0 + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_returns_root_text_count_1_when_text_is_commentary( + self, + mock_fetch_segment_details, + mock_fetch_text, + ): + """Test that root_text count is 1 when the text is a commentary of another text.""" + mock_fetch_segment_details.return_value = {"text_id": COMMENTARY_TEXT_ID} + mock_fetch_text.return_value = { + "id": COMMENTARY_TEXT_ID, + "title": {"bo": "Commentary Title"}, + "language": "bo", + "translations": ["trans-1"], + "commentaries": [], + "translation_of": None, + "commentary_of": "root-text-id", + } + + result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID) + + assert result.segment_info.related_text.root_text == 1 + assert result.segment_info.translations == 1 + assert result.segment_info.related_text.commentaries == 0 + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_returns_empty_counts_when_no_related_texts( + self, + mock_fetch_segment_details, + mock_fetch_text, + ): + """Test that counts are 0 when text has no translations or commentaries.""" + mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID} + mock_fetch_text.return_value = { + "id": ROOT_TEXT_ID, + "title": {"bo": "Root Text"}, + "language": "bo", + "translations": [], + "commentaries": [], + "translation_of": None, + "commentary_of": None, + } + + result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID) + + assert result.segment_info.translations == 0 + assert result.segment_info.related_text.commentaries == 0 + assert result.segment_info.related_text.root_text == 0 + assert result.segment_info.resources.sheets == 0 + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_raises_404_when_segment_not_found( + self, + mock_fetch_segment_details, + ): + """Test that 404 is raised when segment details cannot be fetched.""" + mock_fetch_segment_details.side_effect = Exception("Segment not found") + + with pytest.raises(HTTPException) as exc_info: + await get_segment_info_by_id_from_openpecha(segment_id="missing-segment") + + assert exc_info.value.status_code == 404 + assert "missing-segment" in exc_info.value.detail + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_raises_404_when_text_id_not_in_segment_details( + self, + mock_fetch_segment_details, + mock_fetch_text, + ): + """Test that 404 is raised when segment details don't contain text_id.""" + mock_fetch_segment_details.return_value = {"other_field": "value"} + + with pytest.raises(HTTPException) as exc_info: + await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID) + + assert exc_info.value.status_code == 404 + assert "Text ID not found" in exc_info.value.detail + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_raises_404_when_text_not_found( + self, + mock_fetch_segment_details, + mock_fetch_text, + ): + """Test that 404 is raised when text cannot be fetched.""" + mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID} + mock_fetch_text.return_value = None + + with pytest.raises(HTTPException) as exc_info: + await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID) + + assert exc_info.value.status_code == 404 + assert ROOT_TEXT_ID in exc_info.value.detail + + @pytest.mark.asyncio + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id", + new_callable=AsyncMock, + ) + @patch( + "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details", + new_callable=AsyncMock, + ) + async def test_handles_missing_translations_and_commentaries_keys( + self, + mock_fetch_segment_details, + mock_fetch_text, + ): + """Test that missing translations/commentaries keys default to empty lists.""" + mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID} + mock_fetch_text.return_value = { + "id": ROOT_TEXT_ID, + "title": {"bo": "Root Text"}, + "language": "bo", + } + + result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID) + + assert result.segment_info.translations == 0 + assert result.segment_info.related_text.commentaries == 0 + assert result.segment_info.related_text.root_text == 0 diff --git a/tests/segments/test_segments_openpecha_views.py b/tests/segments/test_segments_openpecha_views.py index 7be0b94d6..6aa007ff2 100644 --- a/tests/segments/test_segments_openpecha_views.py +++ b/tests/segments/test_segments_openpecha_views.py @@ -6,8 +6,12 @@ from pecha_api.app import api from pecha_api.texts.segments.segments_response_models import ( ParentSegment, + SegmentInfo, + SegmentRelatedText, + SegmentResources, V2RelatedSegmentItem, V2SegmentCommentariesResponse, + V2SegmentInfoResponse, V2SegmentResponse, V2SegmentTextDetail, V2SegmentTextGroup, @@ -258,3 +262,132 @@ def test_get_commentaries_upstream_error(self, mock_service): assert response.status_code == 502 assert "upstream" in response.json()["detail"].lower() + + +class TestSegmentsV2InfoEndpoint: + """Tests for the GET /segments/{segment_id}/info endpoint.""" + + @patch( + "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha", + new_callable=AsyncMock, + ) + def test_get_segment_info_success(self, mock_service): + """Test successful retrieval of segment info.""" + mock_service.return_value = V2SegmentInfoResponse( + segment_info=SegmentInfo( + segment_id="048576e2-b2bc-4275-9d6c-220ca7357f3c", + text_id="e159959d-2c0c-4f48-b02c-fbdc8c4a98e3", + translations=5, + related_text=SegmentRelatedText( + commentaries=7, + root_text=0, + ), + resources=SegmentResources( + sheets=0, + ), + ) + ) + + response = client.get(f"{SEGMENTS_ROOT}/048576e2-b2bc-4275-9d6c-220ca7357f3c/info") + + assert response.status_code == 200 + data = response.json() + assert data["segment_info"]["segment_id"] == "048576e2-b2bc-4275-9d6c-220ca7357f3c" + assert data["segment_info"]["text_id"] == "e159959d-2c0c-4f48-b02c-fbdc8c4a98e3" + assert data["segment_info"]["translations"] == 5 + assert data["segment_info"]["related_text"]["commentaries"] == 7 + assert data["segment_info"]["related_text"]["root_text"] == 0 + assert data["segment_info"]["resources"]["sheets"] == 0 + + mock_service.assert_awaited_once_with( + segment_id="048576e2-b2bc-4275-9d6c-220ca7357f3c", + ) + + @patch( + "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha", + new_callable=AsyncMock, + ) + def test_get_segment_info_with_root_text(self, mock_service): + """Test segment info when text is a translation (has root_text).""" + mock_service.return_value = V2SegmentInfoResponse( + segment_info=SegmentInfo( + segment_id="seg-trans-1", + text_id="text-trans-1", + translations=0, + related_text=SegmentRelatedText( + commentaries=0, + root_text=1, + ), + resources=SegmentResources( + sheets=0, + ), + ) + ) + + response = client.get(f"{SEGMENTS_ROOT}/seg-trans-1/info") + + assert response.status_code == 200 + data = response.json() + assert data["segment_info"]["related_text"]["root_text"] == 1 + assert data["segment_info"]["translations"] == 0 + + @patch( + "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha", + new_callable=AsyncMock, + ) + def test_get_segment_info_with_no_related_texts(self, mock_service): + """Test segment info when text has no translations or commentaries.""" + mock_service.return_value = V2SegmentInfoResponse( + segment_info=SegmentInfo( + segment_id="seg-1", + text_id="text-1", + translations=0, + related_text=SegmentRelatedText( + commentaries=0, + root_text=0, + ), + resources=SegmentResources( + sheets=0, + ), + ) + ) + + response = client.get(f"{SEGMENTS_ROOT}/seg-1/info") + + assert response.status_code == 200 + data = response.json() + assert data["segment_info"]["translations"] == 0 + assert data["segment_info"]["related_text"]["commentaries"] == 0 + assert data["segment_info"]["related_text"]["root_text"] == 0 + + @patch( + "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha", + new_callable=AsyncMock, + ) + def test_get_segment_info_not_found(self, mock_service): + """Test 404 when segment is not found.""" + mock_service.side_effect = HTTPException( + status_code=404, + detail="Segment with id 'missing-segment' not found", + ) + + response = client.get(f"{SEGMENTS_ROOT}/missing-segment/info") + + assert response.status_code == 404 + assert "missing-segment" in response.json()["detail"] + + @patch( + "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha", + new_callable=AsyncMock, + ) + def test_get_segment_info_text_not_found(self, mock_service): + """Test 404 when text associated with segment is not found.""" + mock_service.side_effect = HTTPException( + status_code=404, + detail="Text with id 'text-123' not found", + ) + + response = client.get(f"{SEGMENTS_ROOT}/seg-1/info") + + assert response.status_code == 404 + assert "text-123" in response.json()["detail"].lower() From f6434c26b6e746ba585dcf9c9c1ed9cef39009d4 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Wed, 3 Jun 2026 15:35:32 +0530 Subject: [PATCH 17/29] feat(texts): add title filtering to text fetching endpoints - Introduced an optional `title` parameter in the `fetch_texts_by_category` function to filter results based on a case-insensitive substring of the title. - Updated related functions and views to support the new title filtering feature. - Enhanced tests to verify the correct behavior of title filtering in various scenarios, ensuring that the title parameter is correctly passed and handled. - Set coverage threshold to 80% in the CI configuration. --- .github/workflows/github-ci.yml | 2 +- coverage.xml | 7296 +++++++++++++++++ openpecha_api/text/openpecha_text_service.py | 3 + pecha_api/texts/texts_openpecha_service.py | 4 + pecha_api/texts/texts_openpecha_views.py | 2 + pyproject.toml | 1 + .../test_openpecha_segment_service.py | 109 + .../test_openpecha_text_service.py | 53 + tests/texts/test_texts_openpecha_service.py | 56 + tests/texts/test_texts_openpecha_views.py | 20 + 10 files changed, 7545 insertions(+), 1 deletion(-) create mode 100644 coverage.xml create mode 100644 tests/openpecha_api/test_openpecha_segment_service.py diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 0a5a64cdb..29764b266 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -98,7 +98,7 @@ jobs: - name: Run tests with coverage run: | - poetry run pytest --cov=pecha_api --cov-report=xml + poetry run pytest --cov=pecha_api --cov=openpecha_api --cov-report=xml --cov-fail-under=80 - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v5 diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 000000000..f55fd19f9 --- /dev/null +++ b/coverage.xml @@ -0,0 +1,7296 @@ + + + + + + D:\work\WeBuddhist-Backend\pecha_api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openpecha_api/text/openpecha_text_service.py b/openpecha_api/text/openpecha_text_service.py index 7906cc98f..f70c860b2 100644 --- a/openpecha_api/text/openpecha_text_service.py +++ b/openpecha_api/text/openpecha_text_service.py @@ -9,6 +9,7 @@ async def fetch_texts_by_category( category_id: str, language: Optional[str] = None, + title: Optional[str] = None, limit: int = 20, offset: int = 0, ) -> Dict[str, Any]: @@ -19,6 +20,8 @@ async def fetch_texts_by_category( } if language: params["language"] = language + if title: + params["title"] = title client = get_authenticated_open_pecha_client() http_client = client.get_async_httpx_client() diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py index 64c3667e5..6ff99fe3c 100644 --- a/pecha_api/texts/texts_openpecha_service.py +++ b/pecha_api/texts/texts_openpecha_service.py @@ -84,10 +84,12 @@ async def _get_texts_by_collection_id( collection_id: str, skip: int, limit: int, + title: Optional[str] = None, ) -> Tuple[List[V2TextDTO], bool]: try: page = await fetch_texts_by_category( category_id=collection_id, + title=title, offset=skip, limit=limit, ) @@ -107,11 +109,13 @@ async def _get_texts_by_collection_id( async def get_texts_by_collection_from_openpecha( collection_id: str, language: Optional[str] = None, + title: Optional[str] = None, skip: int = 0, limit: int = 10, ) -> V2TextsCategoryResponse: texts, has_more = await _get_texts_by_collection_id( collection_id=collection_id, + title=title, skip=skip, limit=limit, ) diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 7821d6636..0d2aee947 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -32,12 +32,14 @@ async def get_texts_by_collection( collection_id: Annotated[str, Query(description="Collection ID to filter texts")], language: Annotated[Optional[str], Query(description="Language code filter")] = None, + title: Annotated[Optional[str], Query(description="Filter texts by title (case-insensitive substring)")] = None, skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0, limit: Annotated[int, Query(ge=1, le=100, description="Number of records to return")] = 10, ) -> V2TextsCategoryResponse: return await get_texts_by_collection_from_openpecha( collection_id=collection_id, language=language, + title=title, skip=skip, limit=limit, ) diff --git a/pyproject.toml b/pyproject.toml index e6fe7c776..b3e64e57a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ start = "uvicorn:main" omit = [ "*/*_repository.py", "*/*_models.py", "*/*_init__.py", "*/db/*", "*/external_clients/*",] [tool.coverage.report] +fail_under = 80 exclude_lines = [ "pragma: no cover", "def __repr__", "if self.debug:", "raise NotImplementedError", "if __name__ == .__main__.:", "pass", "raise ImportError",] [tool.poetry.dependencies.uvicorn] diff --git a/tests/openpecha_api/test_openpecha_segment_service.py b/tests/openpecha_api/test_openpecha_segment_service.py new file mode 100644 index 000000000..b5221d9d2 --- /dev/null +++ b/tests/openpecha_api/test_openpecha_segment_service.py @@ -0,0 +1,109 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from openpecha_api.segments.openpecha_segment_service import ( + fetch_related_segments, + fetch_segment_content, + fetch_segment_details, +) + + +def _mock_http_client(response_payload): + mock_response = MagicMock() + mock_response.json.return_value = response_payload + mock_response.raise_for_status = MagicMock() + + mock_http = AsyncMock() + mock_http.get.return_value = mock_response + return mock_http + + +@pytest.mark.asyncio +@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client") +async def test_fetch_related_segments(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"items": [{"id": "seg-1"}]} + ) + + result = await fetch_related_segments("seg-1", limit=5, offset=2) + + assert result["items"][0]["id"] == "seg-1" + http = mock_get_client.return_value.get_async_httpx_client.return_value + http.get.assert_awaited_once_with( + "/v2/segments/seg-1/related", + params={"limit": 5, "offset": 2}, + ) + + +@pytest.mark.asyncio +@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client") +async def test_fetch_segment_content_string_response(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + "segment text" + ) + + result = await fetch_segment_content("seg-1") + + assert result == "segment text" + + +@pytest.mark.asyncio +@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client") +async def test_fetch_segment_content_dict_with_content_key(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"content": "from content key"} + ) + + result = await fetch_segment_content("seg-1") + + assert result == "from content key" + + +@pytest.mark.asyncio +@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client") +async def test_fetch_segment_content_dict_with_text_key(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"text": "from text key"} + ) + + result = await fetch_segment_content("seg-1") + + assert result == "from text key" + + +@pytest.mark.asyncio +@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client") +async def test_fetch_segment_content_dict_with_value_key(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"value": "from value key"} + ) + + result = await fetch_segment_content("seg-1") + + assert result == "from value key" + + +@pytest.mark.asyncio +@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client") +async def test_fetch_segment_content_unrecognized_dict_returns_none(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"other": "value"} + ) + + result = await fetch_segment_content("seg-1") + + assert result is None + + +@pytest.mark.asyncio +@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client") +async def test_fetch_segment_details(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"id": "seg-1", "type": "segment"} + ) + + result = await fetch_segment_details("seg-1") + + assert result["id"] == "seg-1" + http = mock_get_client.return_value.get_async_httpx_client.return_value + http.get.assert_awaited_once_with("/v2/segments/seg-1") diff --git a/tests/openpecha_api/test_openpecha_text_service.py b/tests/openpecha_api/test_openpecha_text_service.py index f7d893090..01b11ad14 100644 --- a/tests/openpecha_api/test_openpecha_text_service.py +++ b/tests/openpecha_api/test_openpecha_text_service.py @@ -31,6 +31,59 @@ async def test_fetch_texts_by_category(mock_get_client): ) +@pytest.mark.asyncio +@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client") +async def test_fetch_texts_by_category_omits_title_when_not_provided(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"items": []} + ) + + await fetch_texts_by_category("cat-1", limit=10, offset=0) + + http = mock_get_client.return_value.get_async_httpx_client.return_value + params = http.get.await_args.kwargs["params"] + assert "title" not in params + + +@pytest.mark.asyncio +@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client") +async def test_fetch_texts_by_category_omits_title_when_empty_string(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"items": []} + ) + + await fetch_texts_by_category("cat-1", title="", limit=10, offset=0) + + http = mock_get_client.return_value.get_async_httpx_client.return_value + params = http.get.await_args.kwargs["params"] + assert "title" not in params + + +@pytest.mark.asyncio +@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client") +async def test_fetch_texts_by_category_with_title_filter(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"items": [{"id": "t1"}]} + ) + + result = await fetch_texts_by_category( + "cat-1", language="en", title="sutra", limit=10, offset=0 + ) + + assert result["items"][0]["id"] == "t1" + http = mock_get_client.return_value.get_async_httpx_client.return_value + http.get.assert_awaited_once_with( + "/v2/texts", + params={ + "category_id": "cat-1", + "limit": 10, + "offset": 0, + "language": "en", + "title": "sutra", + }, + ) + + @pytest.mark.asyncio @patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client") async def test_fetch_text_by_id(mock_get_client): diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py index f3892b847..4478f4203 100644 --- a/tests/texts/test_texts_openpecha_service.py +++ b/tests/texts/test_texts_openpecha_service.py @@ -87,6 +87,10 @@ def test_extract_title_none_language(self): result = _extract_title(title_payload, None) assert result == "English Title" + def test_extract_title_invalid_payload_returns_empty_string(self): + assert _extract_title(None) == "" + assert _extract_title(123) == "" + class TestMapExternalTextToDto: """Tests for map_external_text_to_dto function (legacy TextDTO mapper).""" @@ -293,10 +297,35 @@ async def test_passes_skip_and_limit_to_upstream_without_language(self, mock_fet mock_fetch_texts.assert_awaited_once_with( category_id="cat-1", + title=None, offset=5, limit=3, ) + @pytest.mark.asyncio + @patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock) + async def test_passes_title_filter_to_upstream(self, mock_fetch_texts): + mock_fetch_texts.return_value = { + "items": [_text_item("t-en", "en")], + "has_more": False, + } + + texts, has_more = await _get_texts_by_collection_id( + collection_id="cat-1", + skip=0, + limit=10, + title="heart", + ) + + assert len(texts) == 1 + assert has_more is False + mock_fetch_texts.assert_awaited_once_with( + category_id="cat-1", + title="heart", + offset=0, + limit=10, + ) + @pytest.mark.asyncio @patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock) async def test_returns_empty_list_when_no_items(self, mock_fetch_texts): @@ -376,6 +405,32 @@ async def test_get_texts_success(self, mock_fetch_texts, mock_fetch_category): mock_fetch_texts.assert_awaited_once_with( category_id="cat-1", + title=None, + offset=0, + limit=10, + ) + + @pytest.mark.asyncio + @patch("pecha_api.texts.texts_openpecha_service.fetch_category_by_id", new_callable=AsyncMock) + @patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock) + async def test_get_texts_with_title_filter(self, mock_fetch_texts, mock_fetch_category): + mock_fetch_texts.return_value = { + "items": [{"id": "t-en", "title": {"en": "Heart Sutra"}, "language": "en"}], + "has_more": False, + } + mock_fetch_category.return_value = {"title": {"en": "Collection"}} + + result = await get_texts_by_collection_from_openpecha( + collection_id="cat-1", + title="heart", + skip=0, + limit=10, + ) + + assert len(result.texts) == 1 + mock_fetch_texts.assert_awaited_once_with( + category_id="cat-1", + title="heart", offset=0, limit=10, ) @@ -406,6 +461,7 @@ async def test_get_texts_with_pagination(self, mock_fetch_texts, mock_fetch_cate mock_fetch_texts.assert_awaited_once_with( category_id="cat-1", + title=None, offset=1, limit=1, ) diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py index 3a2a3936a..ae47f49bc 100644 --- a/tests/texts/test_texts_openpecha_views.py +++ b/tests/texts/test_texts_openpecha_views.py @@ -98,6 +98,26 @@ def test_get_texts_by_collection_success(self, mock_service): assert data["collection"]["title"] == "Discourses" assert len(data["texts"]) == 2 + @patch("pecha_api.texts.texts_openpecha_views.get_texts_by_collection_from_openpecha") + def test_get_texts_by_collection_with_title_filter(self, mock_service): + mock_service.return_value = V2TextsCategoryResponse( + collection=V2CollectionModel(id="cat-1", title="Discourses"), + texts=[V2TextDTO(id="t1", title="Heart Sutra", language="en")], + skip=0, + limit=10, + ) + + response = client.get("/texts?collection_id=cat-1&title=heart&skip=0&limit=10") + + assert response.status_code == 200 + mock_service.assert_awaited_once_with( + collection_id="cat-1", + language=None, + title="heart", + skip=0, + limit=10, + ) + @patch("pecha_api.texts.texts_openpecha_views.get_text_by_id_from_openpecha") def test_get_text_by_id_success(self, mock_service): mock_service.return_value = V2TextDTO( From 5a1b6d11dae3fa7c3f617b95af5d3543960ba832 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Wed, 3 Jun 2026 15:56:55 +0530 Subject: [PATCH 18/29] feat(mongo_database): enhance MongoDB connection handling in lifespan - Added validation for the MongoDB connection string to ensure it is configured before attempting to initialize the client. - Implemented error handling for invalid connection strings and other exceptions during client initialization, with appropriate logging. - Refactored the initialization of Beanie document models to use a constant list for better maintainability. - Updated tests to cover scenarios where the connection string is empty or invalid, ensuring robust error handling. --- pecha_api/db/mongo_database.py | 61 +++++++++++++++++++----- tests/db/test_mongo_database_lifespan.py | 54 +++++++++++++++++++-- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/pecha_api/db/mongo_database.py b/pecha_api/db/mongo_database.py index 687024e6b..3805532e9 100644 --- a/pecha_api/db/mongo_database.py +++ b/pecha_api/db/mongo_database.py @@ -4,6 +4,7 @@ from beanie import init_beanie from fastapi import FastAPI from motor.motor_asyncio import AsyncIOMotorClient +from pymongo.errors import ConfigurationError from ..topics.topics_models import Topic from ..collections.collections_models import Collection @@ -14,26 +15,64 @@ from ..texts.groups.groups_models import Group from ..config import get +logger = logging.getLogger(__name__) + mongodb_client = None mongodb = None +BEANIE_DOCUMENT_MODELS = [ + Collection, + Term, + Topic, + Text, + Segment, + TableOfContent, + Group, +] + + +def _is_mongo_connection_string_configured(connection_string: str) -> bool: + return bool(connection_string and connection_string.strip()) + @asynccontextmanager async def lifespan(api: FastAPI): global mongodb_client, mongodb - # Initialize the MongoDB client and database - mongodb_client = AsyncIOMotorClient(get("MONGO_CONNECTION_STRING")) - mongodb = mongodb_client[get("MONGO_DATABASE_NAME")] - api.mongodb = mongodb # Attach the database instance to the FastAPI app + api.mongodb = None + + connection_string = get("MONGO_CONNECTION_STRING") + if not _is_mongo_connection_string_configured(connection_string): + logger.warning( + "MONGO_CONNECTION_STRING is not set; MongoDB and Beanie will not be initialized." + ) + yield + return + + try: + mongodb_client = AsyncIOMotorClient(connection_string) + mongodb = mongodb_client[get("MONGO_DATABASE_NAME")] + api.mongodb = mongodb + except ConfigurationError: + logger.exception( + "Invalid MONGO_CONNECTION_STRING; MongoDB will not be initialized." + ) + yield + return + except Exception: + logger.exception( + "Failed to create MongoDB client; MongoDB will not be initialized." + ) + yield + return - # Initialize collections and indexes if necessary try: - await init_beanie(database=mongodb,document_models=[Collection, Term, Topic, Text, Segment, TableOfContent, Group]) - logging.info("Beanie initialized with the 'terms' collection.") - - except Exception as e: - logging.error(f"Error during collection initialization: {e}") - raise + await init_beanie( + database=mongodb, + document_models=BEANIE_DOCUMENT_MODELS, + ) + logger.info("Beanie initialized with MongoDB document models.") + except Exception: + logger.exception("Error during Beanie initialization.") yield diff --git a/tests/db/test_mongo_database_lifespan.py b/tests/db/test_mongo_database_lifespan.py index e3c22bef9..14c0b413f 100644 --- a/tests/db/test_mongo_database_lifespan.py +++ b/tests/db/test_mongo_database_lifespan.py @@ -1,10 +1,18 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pymongo.errors import ConfigurationError from pecha_api.db.mongo_database import lifespan +def _mongo_get_side_effect(key: str) -> str: + return { + "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", + "MONGO_DATABASE_NAME": "testdb", + }[key] + + @pytest.mark.asyncio async def test_lifespan_closes_mongo_client_on_shutdown(): api = MagicMock() @@ -14,10 +22,7 @@ async def test_lifespan_closes_mongo_client_on_shutdown(): new_callable=AsyncMock, ), patch( "pecha_api.db.mongo_database.get", - side_effect=lambda key: { - "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", - "MONGO_DATABASE_NAME": "testdb", - }[key], + side_effect=_mongo_get_side_effect, ): mock_client = MagicMock() mock_client_cls.return_value = mock_client @@ -28,3 +33,44 @@ async def test_lifespan_closes_mongo_client_on_shutdown(): assert api.mongodb is mock_db mock_client.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_lifespan_skips_mongo_when_connection_string_empty(): + api = MagicMock() + + with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch( + "pecha_api.db.mongo_database.init_beanie", + new_callable=AsyncMock, + ), patch( + "pecha_api.db.mongo_database.get", + side_effect=lambda key: { + "MONGO_CONNECTION_STRING": "", + "MONGO_DATABASE_NAME": "testdb", + }[key], + ): + async with lifespan(api): + assert api.mongodb is None + + mock_client_cls.assert_not_called() + + +@pytest.mark.asyncio +async def test_lifespan_skips_mongo_when_connection_string_invalid(): + api = MagicMock() + + with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch( + "pecha_api.db.mongo_database.init_beanie", + new_callable=AsyncMock, + ), patch( + "pecha_api.db.mongo_database.get", + side_effect=_mongo_get_side_effect, + ): + mock_client_cls.side_effect = ConfigurationError( + "Empty host (or extra comma in host list)." + ) + + async with lifespan(api): + assert api.mongodb is None + + mock_client_cls.assert_called_once() From 802eaba270257b06449804e076a08b8c1d7fd9c8 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Wed, 3 Jun 2026 17:16:54 +0530 Subject: [PATCH 19/29] feat(texts): make category_id and collection_id optional in text fetching functions - Updated the `fetch_texts_by_category` and `get_texts_by_collection_from_openpecha` functions to accept `category_id` and `collection_id` as optional parameters. - Adjusted related service and view functions to handle cases where these IDs are not provided, ensuring proper functionality without them. - Enhanced tests to verify the behavior of the updated functions when no category or collection ID is supplied, confirming that the parameters are omitted as expected. --- openpecha_api/text/openpecha_text_service.py | 5 +-- pecha_api/texts/texts_openpecha_service.py | 34 ++++++++++--------- pecha_api/texts/texts_openpecha_views.py | 2 +- pecha_api/texts/texts_response_models.py | 2 +- .../test_openpecha_text_service.py | 15 ++++++++ tests/texts/test_texts_openpecha_service.py | 21 ++++++++++++ tests/texts/test_texts_openpecha_views.py | 23 +++++++++++++ 7 files changed, 82 insertions(+), 20 deletions(-) diff --git a/openpecha_api/text/openpecha_text_service.py b/openpecha_api/text/openpecha_text_service.py index f70c860b2..7d3b28e67 100644 --- a/openpecha_api/text/openpecha_text_service.py +++ b/openpecha_api/text/openpecha_text_service.py @@ -7,17 +7,18 @@ async def fetch_texts_by_category( - category_id: str, + category_id: Optional[str] = None, language: Optional[str] = None, title: Optional[str] = None, limit: int = 20, offset: int = 0, ) -> Dict[str, Any]: params: Dict[str, Any] = { - "category_id": category_id, "limit": limit, "offset": offset, } + if category_id: + params["category_id"] = category_id if language: params["language"] = language if title: diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py index 6ff99fe3c..e936f9fb1 100644 --- a/pecha_api/texts/texts_openpecha_service.py +++ b/pecha_api/texts/texts_openpecha_service.py @@ -81,7 +81,7 @@ def map_external_text_to_dto(item: Dict[str, Any], language: Optional[str] = Non async def _get_texts_by_collection_id( - collection_id: str, + collection_id: Optional[str], skip: int, limit: int, title: Optional[str] = None, @@ -107,7 +107,7 @@ async def _get_texts_by_collection_id( async def get_texts_by_collection_from_openpecha( - collection_id: str, + collection_id: Optional[str] = None, language: Optional[str] = None, title: Optional[str] = None, skip: int = 0, @@ -120,22 +120,24 @@ async def get_texts_by_collection_from_openpecha( limit=limit, ) - category_title = "" - try: - category_data = await fetch_category_by_id(collection_id, language=language) - if category_data: - category_title = _extract_title(category_data.get("title", {}), language) - except Exception: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to fetch category title from upstream service", + collection: Optional[V2CollectionModel] = None + if collection_id: + category_title = "" + try: + category_data = await fetch_category_by_id(collection_id, language=language) + if category_data: + category_title = _extract_title(category_data.get("title", {}), language) + except Exception: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to fetch category title from upstream service", + ) + + collection = V2CollectionModel( + id=collection_id, + title=category_title, ) - collection = V2CollectionModel( - id=collection_id, - title=category_title, - ) - return V2TextsCategoryResponse( collection=collection, texts=texts, diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 0d2aee947..08b346062 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -30,7 +30,7 @@ description="Retrieve texts for a collection from OpenPecha API. " ) async def get_texts_by_collection( - collection_id: Annotated[str, Query(description="Collection ID to filter texts")], + collection_id: Annotated[Optional[str], Query(description="Collection ID to filter texts")] = None, language: Annotated[Optional[str], Query(description="Language code filter")] = None, title: Annotated[Optional[str], Query(description="Filter texts by title (case-insensitive substring)")] = None, skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0, diff --git a/pecha_api/texts/texts_response_models.py b/pecha_api/texts/texts_response_models.py index 1adbb69a5..724bd38e9 100644 --- a/pecha_api/texts/texts_response_models.py +++ b/pecha_api/texts/texts_response_models.py @@ -173,7 +173,7 @@ class TextsCategoryResponse(BaseModel): class V2TextsCategoryResponse(BaseModel): - collection: V2CollectionModel + collection: Optional[V2CollectionModel] = None texts: List[V2TextDTO] skip: int limit: int diff --git a/tests/openpecha_api/test_openpecha_text_service.py b/tests/openpecha_api/test_openpecha_text_service.py index 01b11ad14..deb216fc5 100644 --- a/tests/openpecha_api/test_openpecha_text_service.py +++ b/tests/openpecha_api/test_openpecha_text_service.py @@ -31,6 +31,21 @@ async def test_fetch_texts_by_category(mock_get_client): ) +@pytest.mark.asyncio +@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client") +async def test_fetch_texts_by_category_omits_category_id_when_not_provided(mock_get_client): + mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client( + {"items": []} + ) + + await fetch_texts_by_category(limit=10, offset=0) + + http = mock_get_client.return_value.get_async_httpx_client.return_value + params = http.get.await_args.kwargs["params"] + assert "category_id" not in params + assert params == {"limit": 10, "offset": 0} + + @pytest.mark.asyncio @patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client") async def test_fetch_texts_by_category_omits_title_when_not_provided(mock_get_client): diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py index 4478f4203..326d506b4 100644 --- a/tests/texts/test_texts_openpecha_service.py +++ b/tests/texts/test_texts_openpecha_service.py @@ -489,6 +489,27 @@ async def test_get_texts_category_upstream_error(self, mock_fetch_texts, mock_fe assert exc_info.value.status_code == 502 assert "category" in exc_info.value.detail.lower() + @pytest.mark.asyncio + @patch("pecha_api.texts.texts_openpecha_service.fetch_category_by_id", new_callable=AsyncMock) + @patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock) + async def test_get_texts_without_collection_id(self, mock_fetch_texts, mock_fetch_category): + mock_fetch_texts.return_value = { + "items": [{"id": "t1", "title": {"en": "Text 1"}, "language": "en"}], + "has_more": False, + } + + result = await get_texts_by_collection_from_openpecha(skip=0, limit=10) + + assert result.collection is None + assert len(result.texts) == 1 + mock_fetch_texts.assert_awaited_once_with( + category_id=None, + title=None, + offset=0, + limit=10, + ) + mock_fetch_category.assert_not_awaited() + # ============================================================================= # Service Function Tests - get_text_by_id_from_openpecha (V2) diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py index ae47f49bc..4845d562d 100644 --- a/tests/texts/test_texts_openpecha_views.py +++ b/tests/texts/test_texts_openpecha_views.py @@ -79,6 +79,29 @@ # ============================================================================= class TestTextsV2Endpoint: + @patch("pecha_api.texts.texts_openpecha_views.get_texts_by_collection_from_openpecha") + def test_get_texts_without_collection_id(self, mock_service): + mock_service.return_value = V2TextsCategoryResponse( + collection=None, + texts=[V2TextDTO(id="t1", title="Text 1", language="en")], + skip=0, + limit=10, + ) + + response = client.get("/texts") + + assert response.status_code == 200 + data = response.json() + assert data["collection"] is None + assert len(data["texts"]) == 1 + mock_service.assert_awaited_once_with( + collection_id=None, + language=None, + title=None, + skip=0, + limit=10, + ) + @patch("pecha_api.texts.texts_openpecha_views.get_texts_by_collection_from_openpecha") def test_get_texts_by_collection_success(self, mock_service): mock_service.return_value = V2TextsCategoryResponse( From 07462dced9c282604778f6d05510e809018fa805 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Wed, 3 Jun 2026 17:35:32 +0530 Subject: [PATCH 20/29] language_filter_removed_for_version --- pecha_api/texts/texts_openpecha_service.py | 14 +------------- pecha_api/texts/texts_openpecha_views.py | 2 -- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py index 64c3667e5..88bb5ac5d 100644 --- a/pecha_api/texts/texts_openpecha_service.py +++ b/pecha_api/texts/texts_openpecha_service.py @@ -286,15 +286,6 @@ def map_external_text_to_text_version(item: Dict[str, Any], language: Optional[s ) -def filter_versions_by_language( - versions: List[TextVersion], - language: Optional[str] -) -> List[TextVersion]: - if not language: - return versions - return [v for v in versions if v.language == language] - - def paginate_versions( versions: List[TextVersion], skip: int, @@ -305,7 +296,6 @@ def paginate_versions( async def get_text_versions_from_openpecha( text_id: str, - language: Optional[str] = None, skip: int = 0, limit: int = 10 ) -> TextVersionResponse: @@ -342,9 +332,7 @@ async def get_text_versions_from_openpecha( for item in translation_details ] - filtered_versions = filter_versions_by_language(versions, language) - - paginated_versions = paginate_versions(filtered_versions, skip, limit) + paginated_versions = paginate_versions(versions, skip, limit) return TextVersionResponse( text=root_text, diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 7821d6636..833b24f00 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -66,13 +66,11 @@ async def get_text_by_id(text_id: str) -> V2TextDTO: @texts_v2_router.get("/{text_id}/versions", status_code=status.HTTP_200_OK) async def get_text_versions( text_id: str, - language: Annotated[Optional[str], Query(description="Language code filter")] = None, skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0, limit: Annotated[int, Query(ge=1, le=100, description="Number of records to return")] = 10 ) -> TextVersionResponse: return await get_text_versions_from_openpecha( text_id=text_id, - language=language, skip=skip, limit=limit ) From 76843c7e1f8b5ad7130addb4423d45d47d0b2ca5 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Wed, 3 Jun 2026 17:41:01 +0530 Subject: [PATCH 21/29] test update --- tests/texts/test_texts_openpecha_service.py | 90 --------------------- tests/texts/test_texts_openpecha_views.py | 26 +----- 2 files changed, 1 insertion(+), 115 deletions(-) diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py index f3892b847..66dc9c0e7 100644 --- a/tests/texts/test_texts_openpecha_service.py +++ b/tests/texts/test_texts_openpecha_service.py @@ -8,7 +8,6 @@ _get_texts_by_collection_id, map_external_text_to_dto, map_external_text_to_text_version, - filter_versions_by_language, paginate_versions, fetch_translation_details, fetch_commentary_details, @@ -168,70 +167,6 @@ def test_map_commentary_data(self): assert result.parent_id == "text-123" -class TestFilterVersionsByLanguage: - """Tests for filter_versions_by_language function.""" - - def test_filter_with_language(self): - versions = [ - TextVersion( - id="v1", title="English", parent_id=None, priority=1, - language="en", type="translation", group_id="g1", - table_of_contents=[], is_published=True, - created_date="2025-01-01", updated_date="2025-01-01", - published_date="2025-01-01", published_by="user" - ), - TextVersion( - id="v2", title="Tibetan", parent_id=None, priority=2, - language="bo", type="translation", group_id="g1", - table_of_contents=[], is_published=True, - created_date="2025-01-01", updated_date="2025-01-01", - published_date="2025-01-01", published_by="user" - ) - ] - - result = filter_versions_by_language(versions, "en") - - assert len(result) == 1 - assert result[0].language == "en" - - def test_filter_without_language(self): - versions = [ - TextVersion( - id="v1", title="English", parent_id=None, priority=1, - language="en", type="translation", group_id="g1", - table_of_contents=[], is_published=True, - created_date="2025-01-01", updated_date="2025-01-01", - published_date="2025-01-01", published_by="user" - ), - TextVersion( - id="v2", title="Tibetan", parent_id=None, priority=2, - language="bo", type="translation", group_id="g1", - table_of_contents=[], is_published=True, - created_date="2025-01-01", updated_date="2025-01-01", - published_date="2025-01-01", published_by="user" - ) - ] - - result = filter_versions_by_language(versions, None) - - assert len(result) == 2 - - def test_filter_no_matches(self): - versions = [ - TextVersion( - id="v1", title="English", parent_id=None, priority=1, - language="en", type="translation", group_id="g1", - table_of_contents=[], is_published=True, - created_date="2025-01-01", updated_date="2025-01-01", - published_date="2025-01-01", published_by="user" - ) - ] - - result = filter_versions_by_language(versions, "zh") - - assert len(result) == 0 - - class TestPaginateVersions: """Tests for paginate_versions function.""" @@ -494,7 +429,6 @@ async def test_get_text_versions_success(self, mock_fetch_translations, mock_fet result = await get_text_versions_from_openpecha( text_id="text-123", - language=None, skip=0, limit=10 ) @@ -513,7 +447,6 @@ async def test_get_text_versions_text_not_found(self, mock_fetch_text): with pytest.raises(HTTPException) as exc_info: await get_text_versions_from_openpecha( text_id="nonexistent", - language=None, skip=0, limit=10 ) @@ -529,7 +462,6 @@ async def test_get_text_versions_upstream_error(self, mock_fetch_text): with pytest.raises(HTTPException) as exc_info: await get_text_versions_from_openpecha( text_id="text-123", - language=None, skip=0, limit=10 ) @@ -549,7 +481,6 @@ async def test_get_text_versions_no_translations(self, mock_fetch_text): result = await get_text_versions_from_openpecha( text_id="text-123", - language=None, skip=0, limit=10 ) @@ -557,26 +488,6 @@ async def test_get_text_versions_no_translations(self, mock_fetch_text): assert result.text is not None assert len(result.versions) == 0 - @pytest.mark.asyncio - @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') - @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details') - async def test_get_text_versions_with_language_filter(self, mock_fetch_translations, mock_fetch_text): - mock_fetch_text.return_value = MOCK_EXTERNAL_TEXT_DATA - mock_fetch_translations.return_value = [ - {"id": "t1", "title": {"en": "English"}, "language": "en", "translation_of": "text-123"}, - {"id": "t2", "title": {"bo": "Tibetan"}, "language": "bo", "translation_of": "text-123"} - ] - - result = await get_text_versions_from_openpecha( - text_id="text-123", - language="en", - skip=0, - limit=10 - ) - - assert len(result.versions) == 1 - assert result.versions[0].language == "en" - @pytest.mark.asyncio @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details') @@ -589,7 +500,6 @@ async def test_get_text_versions_with_pagination(self, mock_fetch_translations, result = await get_text_versions_from_openpecha( text_id="text-123", - language=None, skip=2, limit=2 ) diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py index 3a2a3936a..c81cb5d7b 100644 --- a/tests/texts/test_texts_openpecha_views.py +++ b/tests/texts/test_texts_openpecha_views.py @@ -178,28 +178,6 @@ def test_get_text_versions_success(self, mock_service): assert data["versions"][1]["id"] == "version-2" mock_service.assert_called_once_with( text_id="text-123", - language=None, - skip=0, - limit=10 - ) - - @patch('pecha_api.texts.texts_openpecha_views.get_text_versions_from_openpecha') - def test_get_text_versions_with_language_filter(self, mock_service): - mock_response = TextVersionResponse( - text=MOCK_TEXT_DTO, - versions=[MOCK_TEXT_VERSION_1] - ) - mock_service.return_value = mock_response - - response = client.get("/texts/text-123/versions?language=en") - - assert response.status_code == 200 - data = response.json() - assert len(data["versions"]) == 1 - assert data["versions"][0]["language"] == "en" - mock_service.assert_called_once_with( - text_id="text-123", - language="en", skip=0, limit=10 ) @@ -219,7 +197,6 @@ def test_get_text_versions_with_pagination(self, mock_service): assert len(data["versions"]) == 1 mock_service.assert_called_once_with( text_id="text-123", - language=None, skip=1, limit=5 ) @@ -247,12 +224,11 @@ def test_get_text_versions_with_all_parameters(self, mock_service): ) mock_service.return_value = mock_response - response = client.get("/texts/text-123/versions?language=bo&skip=2&limit=20") + response = client.get("/texts/text-123/versions?skip=2&limit=20") assert response.status_code == 200 mock_service.assert_called_once_with( text_id="text-123", - language="bo", skip=2, limit=20 ) From 765e91441a1fd76a71f70ed18b8e21df792e9b12 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Thu, 4 Jun 2026 11:18:14 +0530 Subject: [PATCH 22/29] missing_versions_fetch --- pecha_api/config.py | 6 +- pecha_api/texts/texts_openpecha_service.py | 97 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/pecha_api/config.py b/pecha_api/config.py index 95bc8beaa..2587bcc5c 100644 --- a/pecha_api/config.py +++ b/pecha_api/config.py @@ -74,9 +74,9 @@ APPLICATION = "webuddhist", #pecha api configuration - EXTERNAL_PECHA_API_URL="", - EXTERNAL_DEV_PECHA_API_URL="", - EXTERNAL_OPENPECHA_API_KEY="", + EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app", + EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160", + EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx", EXTERNAL_PECHA_APP_NAME="webuddhist", EXTERNAL_TITLE_SEARCH_API_URL="", diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py index 88bb5ac5d..3a8e82e3d 100644 --- a/pecha_api/texts/texts_openpecha_service.py +++ b/pecha_api/texts/texts_openpecha_service.py @@ -317,6 +317,25 @@ async def get_text_versions_from_openpecha( root_text = map_external_text_to_dto(text_data, text_data.get("language")) + commentary_of = text_data.get("commentary_of") + translation_of = text_data.get("translation_of") + + # If translation_of is not null, fetch versions from the parent text + if translation_of: + return await _fetch_versions_from_parent(translation_of, root_text, skip, limit) + + # If both commentary_of and translation_of are null, check translations and commentaries lists + if not commentary_of and not translation_of: + translation_ids = text_data.get("translations", []) + commentary_ids = text_data.get("commentaries", []) + + # If there are translations or commentaries, fetch versions from the first available ID + related_ids = translation_ids + commentary_ids + if related_ids: + # Fetch versions from the first related text + return await _fetch_versions_from_related(related_ids[0], root_text, skip, limit) + + # Default: fetch translations directly from this text translation_ids = text_data.get("translations", []) if not translation_ids: @@ -340,6 +359,84 @@ async def get_text_versions_from_openpecha( ) +async def _fetch_versions_from_parent( + parent_id: str, + original_text: TextDTO, + skip: int, + limit: int +) -> TextVersionResponse: + """Fetch versions from a parent text (translation_of).""" + try: + parent_data = await fetch_text_by_id(parent_id) + except Exception: + logger.warning(f"Failed to fetch parent text {parent_id}, returning empty versions") + return TextVersionResponse(text=original_text, versions=[]) + + if not parent_data: + return TextVersionResponse(text=original_text, versions=[]) + + translation_ids = parent_data.get("translations", []) + + if not translation_ids: + return TextVersionResponse(text=original_text, versions=[]) + + translation_details = await fetch_translation_details(translation_ids) + + versions = [ + map_external_text_to_text_version(item, item.get("language")) + for item in translation_details + ] + + paginated_versions = paginate_versions(versions, skip, limit) + + return TextVersionResponse( + text=original_text, + versions=paginated_versions + ) + + +async def _fetch_versions_from_related( + related_id: str, + original_text: TextDTO, + skip: int, + limit: int +) -> TextVersionResponse: + """Fetch versions from a related text (from translations or commentaries list).""" + try: + related_data = await fetch_text_by_id(related_id) + except Exception: + logger.warning(f"Failed to fetch related text {related_id}, returning empty versions") + return TextVersionResponse(text=original_text, versions=[]) + + if not related_data: + return TextVersionResponse(text=original_text, versions=[]) + + # Check if the related text has a translation_of pointing to a parent + translation_of = related_data.get("translation_of") + if translation_of: + return await _fetch_versions_from_parent(translation_of, original_text, skip, limit) + + # Otherwise, get translations from the related text itself + translation_ids = related_data.get("translations", []) + + if not translation_ids: + return TextVersionResponse(text=original_text, versions=[]) + + translation_details = await fetch_translation_details(translation_ids) + + versions = [ + map_external_text_to_text_version(item, item.get("language")) + for item in translation_details + ] + + paginated_versions = paginate_versions(versions, skip, limit) + + return TextVersionResponse( + text=original_text, + versions=paginated_versions + ) + + async def fetch_commentary_details(commentary_ids: List[str]) -> List[Dict[str, Any]]: commentary_details = [] for commentary_id in commentary_ids: From 03a88eb1b46d554615f619a409f2df2038724f94 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Thu, 4 Jun 2026 11:28:52 +0530 Subject: [PATCH 23/29] etch_missing_commentary --- pecha_api/texts/texts_openpecha_service.py | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py index 3a8e82e3d..ca24d59e6 100644 --- a/pecha_api/texts/texts_openpecha_service.py +++ b/pecha_api/texts/texts_openpecha_service.py @@ -471,6 +471,24 @@ async def get_text_commentaries_from_openpecha( detail=f"Text with id '{text_id}' not found", ) + commentary_of = text_data.get("commentary_of") + translation_of = text_data.get("translation_of") + + # If commentary_of is not null, fetch commentaries from the parent text + if commentary_of: + return await _fetch_commentaries_from_parent(commentary_of, skip, limit) + + # If both commentary_of and translation_of are null, check translations and commentaries lists + if not commentary_of and not translation_of: + translation_ids = text_data.get("translations", []) + commentary_ids = text_data.get("commentaries", []) + + # If there are translations or commentaries, fetch commentaries from the first available ID + related_ids = translation_ids + commentary_ids + if related_ids: + return await _fetch_commentaries_from_related(related_ids[0], skip, limit) + + # Default: fetch commentaries directly from this text commentary_ids = text_data.get("commentaries", []) if not commentary_ids: @@ -486,3 +504,69 @@ async def get_text_commentaries_from_openpecha( paginated_commentaries = commentaries[skip:skip + limit] return paginated_commentaries + + +async def _fetch_commentaries_from_parent( + parent_id: str, + skip: int, + limit: int +) -> List[TextDTO]: + """Fetch commentaries from a parent text (commentary_of).""" + try: + parent_data = await fetch_text_by_id(parent_id) + except Exception: + logger.warning(f"Failed to fetch parent text {parent_id}, returning empty commentaries") + return [] + + if not parent_data: + return [] + + commentary_ids = parent_data.get("commentaries", []) + + if not commentary_ids: + return [] + + commentary_details = await fetch_commentary_details(commentary_ids) + + commentaries = [ + map_external_text_to_dto(item, item.get("language")) + for item in commentary_details + ] + + return commentaries[skip:skip + limit] + + +async def _fetch_commentaries_from_related( + related_id: str, + skip: int, + limit: int +) -> List[TextDTO]: + """Fetch commentaries from a related text (from translations or commentaries list).""" + try: + related_data = await fetch_text_by_id(related_id) + except Exception: + logger.warning(f"Failed to fetch related text {related_id}, returning empty commentaries") + return [] + + if not related_data: + return [] + + # Check if the related text has a commentary_of pointing to a parent + commentary_of = related_data.get("commentary_of") + if commentary_of: + return await _fetch_commentaries_from_parent(commentary_of, skip, limit) + + # Otherwise, get commentaries from the related text itself + commentary_ids = related_data.get("commentaries", []) + + if not commentary_ids: + return [] + + commentary_details = await fetch_commentary_details(commentary_ids) + + commentaries = [ + map_external_text_to_dto(item, item.get("language")) + for item in commentary_details + ] + + return commentaries[skip:skip + limit] From 4356b5dceea615f60c6c12ab29c3a4d4a4f2b069 Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Thu, 4 Jun 2026 11:41:22 +0530 Subject: [PATCH 24/29] test update --- pecha_api/config.py | 6 +- tests/texts/test_texts_openpecha_service.py | 390 +++++++++++++++++++- 2 files changed, 392 insertions(+), 4 deletions(-) diff --git a/pecha_api/config.py b/pecha_api/config.py index 2587bcc5c..95bc8beaa 100644 --- a/pecha_api/config.py +++ b/pecha_api/config.py @@ -74,9 +74,9 @@ APPLICATION = "webuddhist", #pecha api configuration - EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app", - EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160", - EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx", + EXTERNAL_PECHA_API_URL="", + EXTERNAL_DEV_PECHA_API_URL="", + EXTERNAL_OPENPECHA_API_KEY="", EXTERNAL_PECHA_APP_NAME="webuddhist", EXTERNAL_TITLE_SEARCH_API_URL="", diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py index 66dc9c0e7..9f81c7d08 100644 --- a/tests/texts/test_texts_openpecha_service.py +++ b/tests/texts/test_texts_openpecha_service.py @@ -6,6 +6,10 @@ _extract_title, _map_external_text_to_dto, _get_texts_by_collection_id, + _fetch_versions_from_parent, + _fetch_versions_from_related, + _fetch_commentaries_from_parent, + _fetch_commentaries_from_related, map_external_text_to_dto, map_external_text_to_text_version, paginate_versions, @@ -424,7 +428,22 @@ class TestGetTextVersionsFromOpenpecha: @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details') async def test_get_text_versions_success(self, mock_fetch_translations, mock_fetch_text): - mock_fetch_text.return_value = MOCK_EXTERNAL_TEXT_DATA + """Test default behavior when commentary_of is set (skips related text lookup).""" + # Text with commentary_of set - goes to default behavior + text_data = { + "id": "text-123", + "bdrc": "bdrc-123", + "title": {"en": "Heart Sutra", "bo": "ཤེས་རབ་སྙིང་པོ།"}, + "language": "bo", + "category_id": "cat-1", + "date": "2025-01-01", + "license": "CC0", + "commentary_of": "parent-text", # Has commentary_of, so default behavior applies + "translation_of": None, + "translations": ["trans-1", "trans-2"], + "commentaries": ["comm-1"] + } + mock_fetch_text.return_value = text_data mock_fetch_translations.return_value = [MOCK_TRANSLATION_DATA] result = await get_text_versions_from_openpecha( @@ -506,6 +525,154 @@ async def test_get_text_versions_with_pagination(self, mock_fetch_translations, assert len(result.versions) == 2 + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details') + async def test_get_text_versions_with_translation_of(self, mock_fetch_translations, mock_fetch_text): + """When translation_of is not null, fetch versions from the parent text.""" + # First call: the requested text has translation_of pointing to parent + translation_text = { + "id": "trans-1", + "title": {"en": "Translation"}, + "language": "en", + "translation_of": "root-text-123", + "commentary_of": None, + "translations": [], + "commentaries": [] + } + # Second call: the parent text has translations + parent_text = { + "id": "root-text-123", + "title": {"bo": "Root Text"}, + "language": "bo", + "translations": ["trans-1", "trans-2"], + "commentaries": [] + } + mock_fetch_text.side_effect = [translation_text, parent_text] + mock_fetch_translations.return_value = [ + {"id": "trans-1", "title": {"en": "Translation 1"}, "language": "en", "translation_of": "root-text-123"}, + {"id": "trans-2", "title": {"en": "Translation 2"}, "language": "zh", "translation_of": "root-text-123"} + ] + + result = await get_text_versions_from_openpecha(text_id="trans-1", skip=0, limit=10) + + assert result.text.id == "trans-1" + assert len(result.versions) == 2 + assert result.versions[0].id == "trans-1" + assert result.versions[1].id == "trans-2" + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details') + async def test_get_text_versions_both_null_with_translations_list(self, mock_fetch_translations, mock_fetch_text): + """When both commentary_of and translation_of are null, check translations list.""" + # Root text with translations list + root_text = { + "id": "root-123", + "title": {"bo": "Root Text"}, + "language": "bo", + "translation_of": None, + "commentary_of": None, + "translations": ["trans-1"], + "commentaries": [] + } + # The translation has translation_of pointing to a parent + translation_text = { + "id": "trans-1", + "title": {"en": "Translation"}, + "language": "en", + "translation_of": "root-123", + "translations": [], + "commentaries": [] + } + # Parent text (root-123) has translations + parent_text = { + "id": "root-123", + "title": {"bo": "Root Text"}, + "language": "bo", + "translations": ["trans-1", "trans-2"], + "commentaries": [] + } + mock_fetch_text.side_effect = [root_text, translation_text, parent_text] + mock_fetch_translations.return_value = [ + {"id": "trans-1", "title": {"en": "Translation 1"}, "language": "en"}, + {"id": "trans-2", "title": {"en": "Translation 2"}, "language": "zh"} + ] + + result = await get_text_versions_from_openpecha(text_id="root-123", skip=0, limit=10) + + assert result.text.id == "root-123" + assert len(result.versions) == 2 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_get_text_versions_translation_of_parent_not_found(self, mock_fetch_text): + """When translation_of parent is not found, return empty versions.""" + translation_text = { + "id": "trans-1", + "title": {"en": "Translation"}, + "language": "en", + "translation_of": "missing-parent", + "commentary_of": None + } + mock_fetch_text.side_effect = [translation_text, None] + + result = await get_text_versions_from_openpecha(text_id="trans-1", skip=0, limit=10) + + assert result.text.id == "trans-1" + assert len(result.versions) == 0 + + +# ============================================================================= +# Service Function Tests - _fetch_versions_from_parent +# ============================================================================= + +class TestFetchVersionsFromParent: + """Tests for _fetch_versions_from_parent helper function.""" + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details') + async def test_fetch_versions_from_parent_success(self, mock_fetch_translations, mock_fetch_text): + original_text = MagicMock(spec=TextDTO) + original_text.id = "trans-1" + parent_data = { + "id": "root-123", + "translations": ["trans-1", "trans-2"] + } + mock_fetch_text.return_value = parent_data + mock_fetch_translations.return_value = [ + {"id": "trans-1", "title": {"en": "Trans 1"}, "language": "en"}, + {"id": "trans-2", "title": {"en": "Trans 2"}, "language": "zh"} + ] + + result = await _fetch_versions_from_parent("root-123", original_text, 0, 10) + + assert result.text == original_text + assert len(result.versions) == 2 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_fetch_versions_from_parent_not_found(self, mock_fetch_text): + original_text = MagicMock(spec=TextDTO) + mock_fetch_text.return_value = None + + result = await _fetch_versions_from_parent("missing", original_text, 0, 10) + + assert result.text == original_text + assert len(result.versions) == 0 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_fetch_versions_from_parent_error(self, mock_fetch_text): + original_text = MagicMock(spec=TextDTO) + mock_fetch_text.side_effect = Exception("Connection error") + + result = await _fetch_versions_from_parent("root-123", original_text, 0, 10) + + assert result.text == original_text + assert len(result.versions) == 0 + # ============================================================================= # Service Function Tests - get_text_commentaries_from_openpecha @@ -691,6 +858,227 @@ async def test_get_commentaries_partial_fetch_failure(self, mock_fetch_commentar assert len(result) == 1 assert result[0].id == "c1" + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details') + async def test_get_commentaries_with_commentary_of(self, mock_fetch_commentaries, mock_fetch_text): + """When commentary_of is not null, fetch commentaries from the parent text.""" + # First call: the requested text has commentary_of pointing to parent + commentary_text = { + "id": "comm-1", + "title": {"en": "Commentary"}, + "language": "bo", + "commentary_of": "root-text-123", + "translation_of": None, + "translations": [], + "commentaries": [] + } + # Second call: the parent text has commentaries + parent_text = { + "id": "root-text-123", + "title": {"bo": "Root Text"}, + "language": "bo", + "commentaries": ["comm-1", "comm-2"] + } + mock_fetch_text.side_effect = [commentary_text, parent_text] + mock_fetch_commentaries.return_value = [ + {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"}, + {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"} + ] + + result = await get_text_commentaries_from_openpecha(text_id="comm-1", skip=0, limit=10) + + assert len(result) == 2 + assert result[0].id == "comm-1" + assert result[1].id == "comm-2" + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details') + async def test_get_commentaries_both_null_with_translations_list(self, mock_fetch_commentaries, mock_fetch_text): + """When both commentary_of and translation_of are null, check translations/commentaries list.""" + # Root text with translations list + root_text = { + "id": "root-123", + "title": {"bo": "Root Text"}, + "language": "bo", + "translation_of": None, + "commentary_of": None, + "translations": ["trans-1"], + "commentaries": [] + } + # The translation has commentary_of pointing to a parent + translation_text = { + "id": "trans-1", + "title": {"en": "Translation"}, + "language": "en", + "commentary_of": "root-123", + "translations": [], + "commentaries": [] + } + # Parent text (root-123) has commentaries + parent_text = { + "id": "root-123", + "title": {"bo": "Root Text"}, + "language": "bo", + "commentaries": ["comm-1", "comm-2"] + } + mock_fetch_text.side_effect = [root_text, translation_text, parent_text] + mock_fetch_commentaries.return_value = [ + {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"}, + {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"} + ] + + result = await get_text_commentaries_from_openpecha(text_id="root-123", skip=0, limit=10) + + assert len(result) == 2 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_get_commentaries_commentary_of_parent_not_found(self, mock_fetch_text): + """When commentary_of parent is not found, return empty commentaries.""" + commentary_text = { + "id": "comm-1", + "title": {"en": "Commentary"}, + "language": "bo", + "commentary_of": "missing-parent", + "translation_of": None + } + mock_fetch_text.side_effect = [commentary_text, None] + + result = await get_text_commentaries_from_openpecha(text_id="comm-1", skip=0, limit=10) + + assert len(result) == 0 + + +# ============================================================================= +# Service Function Tests - _fetch_commentaries_from_parent +# ============================================================================= + +class TestFetchCommentariesFromParent: + """Tests for _fetch_commentaries_from_parent helper function.""" + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details') + async def test_fetch_commentaries_from_parent_success(self, mock_fetch_commentaries, mock_fetch_text): + parent_data = { + "id": "root-123", + "commentaries": ["comm-1", "comm-2"] + } + mock_fetch_text.return_value = parent_data + mock_fetch_commentaries.return_value = [ + {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"}, + {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"} + ] + + result = await _fetch_commentaries_from_parent("root-123", 0, 10) + + assert len(result) == 2 + assert result[0].id == "comm-1" + assert result[1].id == "comm-2" + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_fetch_commentaries_from_parent_not_found(self, mock_fetch_text): + mock_fetch_text.return_value = None + + result = await _fetch_commentaries_from_parent("missing", 0, 10) + + assert len(result) == 0 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_fetch_commentaries_from_parent_error(self, mock_fetch_text): + mock_fetch_text.side_effect = Exception("Connection error") + + result = await _fetch_commentaries_from_parent("root-123", 0, 10) + + assert len(result) == 0 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_fetch_commentaries_from_parent_no_commentaries(self, mock_fetch_text): + parent_data = { + "id": "root-123", + "commentaries": [] + } + mock_fetch_text.return_value = parent_data + + result = await _fetch_commentaries_from_parent("root-123", 0, 10) + + assert len(result) == 0 + + +# ============================================================================= +# Service Function Tests - _fetch_commentaries_from_related +# ============================================================================= + +class TestFetchCommentariesFromRelated: + """Tests for _fetch_commentaries_from_related helper function.""" + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details') + async def test_fetch_commentaries_from_related_with_commentary_of(self, mock_fetch_commentaries, mock_fetch_text): + """When related text has commentary_of, fetch from that parent.""" + related_data = { + "id": "trans-1", + "commentary_of": "root-123", + "commentaries": [] + } + parent_data = { + "id": "root-123", + "commentaries": ["comm-1"] + } + mock_fetch_text.side_effect = [related_data, parent_data] + mock_fetch_commentaries.return_value = [ + {"id": "comm-1", "title": {"en": "Commentary"}, "language": "bo"} + ] + + result = await _fetch_commentaries_from_related("trans-1", 0, 10) + + assert len(result) == 1 + assert result[0].id == "comm-1" + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details') + async def test_fetch_commentaries_from_related_direct(self, mock_fetch_commentaries, mock_fetch_text): + """When related text has no commentary_of, fetch its own commentaries.""" + related_data = { + "id": "trans-1", + "commentary_of": None, + "commentaries": ["comm-1", "comm-2"] + } + mock_fetch_text.return_value = related_data + mock_fetch_commentaries.return_value = [ + {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"}, + {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"} + ] + + result = await _fetch_commentaries_from_related("trans-1", 0, 10) + + assert len(result) == 2 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_fetch_commentaries_from_related_not_found(self, mock_fetch_text): + mock_fetch_text.return_value = None + + result = await _fetch_commentaries_from_related("missing", 0, 10) + + assert len(result) == 0 + + @pytest.mark.asyncio + @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id') + async def test_fetch_commentaries_from_related_error(self, mock_fetch_text): + mock_fetch_text.side_effect = Exception("Connection error") + + result = await _fetch_commentaries_from_related("trans-1", 0, 10) + + assert len(result) == 0 + class TestFetchCommentaryDetails: """Tests for fetch_commentary_details function.""" From 6b49dcc016f93c17ff7d8f85fd3c383ffeb78c5a Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Thu, 4 Jun 2026 15:30:26 +0530 Subject: [PATCH 25/29] changes --- .../segments/openpecha_segment_service.py | 3 ++ .../segments/segments_openpecha_service.py | 39 +++++-------------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/openpecha_api/segments/openpecha_segment_service.py b/openpecha_api/segments/openpecha_segment_service.py index edea57bd4..a0ae16704 100644 --- a/openpecha_api/segments/openpecha_segment_service.py +++ b/openpecha_api/segments/openpecha_segment_service.py @@ -6,12 +6,15 @@ async def fetch_related_segments( segment_id: str, limit: int = 10, offset: int = 0, + text_id: Optional[str] = None, ) -> Dict[str, Any]: params: Dict[str, Any] = { "limit": limit, "offset": offset, } + if text_id: + params["text_id"] = text_id client = get_authenticated_open_pecha_client() http_client = client.get_async_httpx_client() response = await http_client.get( diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py index 1d5e9684b..ad6094107 100644 --- a/pecha_api/texts/segments/segments_openpecha_service.py +++ b/pecha_api/texts/segments/segments_openpecha_service.py @@ -32,10 +32,8 @@ TRANSLATION = "translation" COMMENTARY = "commentary" -RELATED_SEGMENTS_MAX_PAGE_SIZE = 100 MAX_PAGINATION_SKIP = 10000 MAX_PAGINATION_LIMIT = 100 -MAX_FETCH_ITERATIONS = 100 def _classify_text(text_payload: Dict[str, Any]) -> Optional[str]: @@ -158,35 +156,18 @@ async def _fetch_matching_related_segments_by_text_id( skip: int, limit: int, ) -> Tuple[List[Dict[str, Any]], bool]: - # Sanitize user-controlled inputs to prevent unbounded loops safe_skip = max(0, min(skip, MAX_PAGINATION_SKIP)) safe_limit = max(1, min(limit, MAX_PAGINATION_LIMIT)) - - matching_items: List[Dict[str, Any]] = [] - upstream_offset = 0 - upstream_has_more = True - target_count = safe_skip + safe_limit + 1 - iterations = 0 - - while upstream_has_more and len(matching_items) < target_count and iterations < MAX_FETCH_ITERATIONS: - iterations += 1 - related_page = await fetch_related_segments( - segment_id=segment_id, - limit=RELATED_SEGMENTS_MAX_PAGE_SIZE, - offset=upstream_offset, - ) - items: List[Dict[str, Any]] = related_page.get("items", []) or [] - matching_items.extend( - item for item in items if item.get("text_id") == text_id - ) - upstream_has_more = bool(related_page.get("has_more", False)) - if not items: - break - upstream_offset += len(items) - - paginated_items = matching_items[safe_skip : safe_skip + safe_limit] - has_more = len(matching_items) > safe_skip + safe_limit - return paginated_items, has_more + + related_page = await fetch_related_segments( + segment_id=segment_id, + limit=safe_limit, + offset=safe_skip, + text_id=text_id, + ) + items: List[Dict[str, Any]] = related_page.get("items", []) or [] + has_more = bool(related_page.get("has_more", False)) + return items, has_more async def get_root_text_by_segment_id_from_openpecha( From a1cb31484b6e6b6628accee9c94db18938ef4bef Mon Sep 17 00:00:00 2001 From: Lungsangg Date: Thu, 4 Jun 2026 22:32:19 +0530 Subject: [PATCH 26/29] text_id_requirement --- pecha_api/texts/segments/segments_openpecha_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py index 1ee40cea4..ffbc9ca4f 100644 --- a/pecha_api/texts/segments/segments_openpecha_views.py +++ b/pecha_api/texts/segments/segments_openpecha_views.py @@ -37,8 +37,8 @@ async def get_segment_v2( summary="Get Root Text for Segment", ) async def get_root_text_for_segment_v2( - text_id: str, segment_id: str, + text_id: str = Query(...), skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 10, ) -> V2SegmentRootTextResponse: From fd02091af8b873e3f3561d47ca26e72988aac24a Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Tue, 9 Jun 2026 11:12:08 +0530 Subject: [PATCH 27/29] feat(texts): add search_text_content function to enable content-based text searches --- openpecha_api/text/openpecha_text_service.py | 26 ++++++++++++++++++++ pecha_api/texts/texts_openpecha_service.py | 18 +++++++++++++- pecha_api/texts/texts_openpecha_views.py | 2 ++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/openpecha_api/text/openpecha_text_service.py b/openpecha_api/text/openpecha_text_service.py index 7d3b28e67..a134830fb 100644 --- a/openpecha_api/text/openpecha_text_service.py +++ b/openpecha_api/text/openpecha_text_service.py @@ -37,3 +37,29 @@ async def fetch_text_by_id(text_id: str) -> Optional[Dict[str, Any]]: response = await http_client.get(f"/v2/texts/{text_id}") response.raise_for_status() return response.json() + + +async def search_by_content( + query: str, + search_type: Optional[str] = None, + limit: Optional[int] = 10, + text_id: Optional[str] = None, + edition_id: Optional[str] = None, +) -> Dict[str, Any]: + + params: Dict[str, Any] = { + "query": query, + "limit": limit, + } + if search_type: + params["search_type"] = search_type + if text_id: + params["text_id"] = text_id + if edition_id: + params["edition_id"] = edition_id + + client = get_authenticated_open_pecha_client() + http_client = client.get_async_httpx_client() + response = await http_client.get("/v2/content-search", params=params) + response.raise_for_status() + return response.json() diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py index 6594b0801..ac92f1d02 100644 --- a/pecha_api/texts/texts_openpecha_service.py +++ b/pecha_api/texts/texts_openpecha_service.py @@ -12,7 +12,7 @@ V2TextsCategoryResponse, ) from pecha_api.collections.collections_response_models import V2CollectionModel -from openpecha_api.text.openpecha_text_service import fetch_texts_by_category, fetch_text_by_id +from openpecha_api.text.openpecha_text_service import fetch_texts_by_category, fetch_text_by_id, search_by_content from openpecha_api.collection.openpecha_collection_service import fetch_category_by_id from pecha_api.texts.texts_openpecha_api import fetch_critical_editions, fetch_text_detail, fetch_editions_segmentation, fetch_segmentation_segments, fetch_edition_content from pecha_api.texts.text_openpecha_response_models import ( @@ -576,3 +576,19 @@ async def _fetch_commentaries_from_related( ] return commentaries[skip:skip + limit] + + + + +CONTENT_SEARCH_URL = "http://13.250.189.160/v2/content-search" + + +async def search_text_content( + query: str, + search_type: Optional[str] = None, + limit: Optional[int] = 10, + text_id: Optional[str] = None, + edition_id: Optional[str] = None, +) -> Dict[str, Any]: + + return await search_by_content(query, search_type, limit, text_id, edition_id) \ No newline at end of file diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py index 5b02d0932..e7961f5d0 100644 --- a/pecha_api/texts/texts_openpecha_views.py +++ b/pecha_api/texts/texts_openpecha_views.py @@ -89,3 +89,5 @@ async def get_text_commentaries( skip=skip, limit=limit ) + + From cc3cf5d5272090153917a6c6c796920d525b0408 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Tue, 9 Jun 2026 11:41:08 +0530 Subject: [PATCH 28/29] feat(search): add V2ContentSearchType enum and include new search router --- pecha_api/app.py | 2 + pecha_api/config.py | 4 +- pecha_api/search/search_enums.py | 4 + pecha_api/search/search_openpecha_service.py | 202 +++++++++++++++++++ pecha_api/search/search_openpecha_views.py | 32 +++ 5 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 pecha_api/search/search_openpecha_service.py create mode 100644 pecha_api/search/search_openpecha_views.py diff --git a/pecha_api/app.py b/pecha_api/app.py index 48b9616e4..956a8b968 100644 --- a/pecha_api/app.py +++ b/pecha_api/app.py @@ -12,6 +12,7 @@ from pecha_api.texts.groups import groups_views from pecha_api.share import share_views from pecha_api.search import search_views +from pecha_api.search import search_openpecha_views from pecha_api.plans.auth import plan_auth_views from pecha_api.plans.cms import cms_plans_views as cms_plans_views from pecha_api.plans.series import series_view as cms_series_views @@ -52,6 +53,7 @@ api.include_router(topics_views.topics_router) api.include_router(users_views.user_router) api.include_router(search_views.search_router) +api.include_router(search_openpecha_views.search_v2_router) api.include_router(share_views.share_router) api.include_router(plan_auth_views.plan_auth_router) api.include_router(cms_plans_views.cms_plans_router) diff --git a/pecha_api/config.py b/pecha_api/config.py index 95bc8beaa..19cb2f561 100644 --- a/pecha_api/config.py +++ b/pecha_api/config.py @@ -75,8 +75,8 @@ #pecha api configuration EXTERNAL_PECHA_API_URL="", - EXTERNAL_DEV_PECHA_API_URL="", - EXTERNAL_OPENPECHA_API_KEY="", + EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160/", + EXTERNAL_OPENPECHA_API_KEY="http://13.250.189.160/", EXTERNAL_PECHA_APP_NAME="webuddhist", EXTERNAL_TITLE_SEARCH_API_URL="", diff --git a/pecha_api/search/search_enums.py b/pecha_api/search/search_enums.py index 0d2ba5fbe..d22f22686 100644 --- a/pecha_api/search/search_enums.py +++ b/pecha_api/search/search_enums.py @@ -16,3 +16,7 @@ class MultilingualSearchType(Enum): BM25 = "bm25" # Keyword-based ranking algorithm SEMANTIC = "semantic" # Vector similarity search EXACT = "exact" # Exact text matching + +class V2ContentSearchType(Enum): + EXACT = "exact" + SIMILAR = "similar" diff --git a/pecha_api/search/search_openpecha_service.py b/pecha_api/search/search_openpecha_service.py new file mode 100644 index 000000000..9f66f8d00 --- /dev/null +++ b/pecha_api/search/search_openpecha_service.py @@ -0,0 +1,202 @@ +import logging +from typing import Any, Dict, List, Optional + +from openpecha_api.text.openpecha_text_service import fetch_text_by_id, search_by_content + +from pecha_api.texts.segments.segments_models import Segment +from .search_response_models import ( + MultilingualSearchResponse, + MultilingualSegmentMatch, + MultilingualSourceResult, + TextIndex, +) +from .search_service import ( + apply_pagination_to_sources, + create_empty_search_response, + fetch_segments_by_ids, + fetch_text_info, +) + +logger = logging.getLogger(__name__) + + +def _extract_title(title_payload: Any, language: Optional[str] = None) -> str: + if isinstance(title_payload, dict): + if language and language in title_payload: + return title_payload[language] + for value in title_payload.values(): + if isinstance(value, str) and value.strip(): + return value + return "" + if isinstance(title_payload, str): + return title_payload.strip() + return "" + + +def _flatten_content_search_matches( + content_search_results: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + matches: List[Dict[str, Any]] = [] + best_scores: Dict[str, float] = {} + + for result in content_search_results: + relevance_score = -result.get("score", 0.0) + context = result.get("context", "") + text_id = result.get("text_id", "") + + for pecha_segment_id in result.get("segment_ids", []): + if not pecha_segment_id: + continue + + existing_score = best_scores.get(pecha_segment_id) + if existing_score is not None and relevance_score >= existing_score: + continue + + best_scores[pecha_segment_id] = relevance_score + matches.append( + { + "text_id": text_id, + "pecha_segment_id": pecha_segment_id, + "content": context, + "relevance_score": relevance_score, + } + ) + + matches.sort(key=lambda match: match["relevance_score"]) + return matches + + +async def _fetch_text_info_map(text_ids: List[str]) -> Dict[str, TextIndex]: + text_info_map = await fetch_text_info(text_ids) + + for text_id in text_ids: + if text_id in text_info_map: + continue + + try: + data = await fetch_text_by_id(text_id) + except Exception: + logger.warning("Failed to fetch text %s from OpenPecha", text_id, exc_info=True) + continue + + if not data: + continue + + language = data.get("language") or "" + text_info_map[text_id] = TextIndex( + text_id=text_id, + language=language, + title=_extract_title(data.get("title", {}), language), + published_date=str(data.get("date") or ""), + ) + + return text_info_map + + +async def _build_sources_from_content_search_matches( + matches: List[Dict[str, Any]], + local_segments_by_pecha: Dict[str, Segment], +) -> List[MultilingualSourceResult]: + text_to_matches: Dict[str, List[MultilingualSegmentMatch]] = {} + text_ids: set[str] = set() + + for match in matches: + pecha_segment_id = match["pecha_segment_id"] + local_segment = local_segments_by_pecha.get(pecha_segment_id) + text_id = local_segment.text_id if local_segment else match["text_id"] + + if not text_id: + continue + + text_ids.add(text_id) + text_to_matches.setdefault(text_id, []).append( + MultilingualSegmentMatch( + segment_id=str(local_segment.id) if local_segment else pecha_segment_id, + content=local_segment.content if local_segment else match["content"], + relevance_score=match["relevance_score"], + pecha_segment_id=pecha_segment_id, + ) + ) + + if not text_ids: + return [] + + text_info_map = await _fetch_text_info_map(list(text_ids)) + sources: List[MultilingualSourceResult] = [] + + for text_id, segment_matches in text_to_matches.items(): + if text_id not in text_info_map: + continue + + segment_matches.sort(key=lambda item: item.relevance_score) + sources.append( + MultilingualSourceResult( + text=text_info_map[text_id], + segment_matches=segment_matches, + ) + ) + + return sources + + +async def get_v2_multilingual_search_results( + query: str, + search_type: str = "similar", + text_id: Optional[str] = None, + edition_id: Optional[str] = None, + skip: int = 0, + limit: int = 10, +) -> MultilingualSearchResponse: + try: + external_limit = min(limit * 5, 100) + + external_data = await search_by_content( + query=query, + search_type=search_type, + limit=external_limit, + text_id=text_id, + edition_id=edition_id, + ) + + if not isinstance(external_data, list): + logger.warning( + "Unexpected OpenPecha content search response type: %s", + type(external_data).__name__, + ) + return create_empty_search_response(query, search_type, skip, limit) + + matches = _flatten_content_search_matches(external_data) + if not matches: + logger.info("No matches returned from OpenPecha content search") + return create_empty_search_response(query, search_type, skip, limit) + + pecha_segment_ids = [match["pecha_segment_id"] for match in matches] + local_segments = await fetch_segments_by_ids(pecha_segment_ids, text_id) + local_segments_by_pecha = { + segment.pecha_segment_id: segment + for segment in local_segments + if segment.pecha_segment_id + } + + sources = await _build_sources_from_content_search_matches( + matches, + local_segments_by_pecha, + ) + + if not sources: + return create_empty_search_response(query, search_type, skip, limit) + + paginated_sources = apply_pagination_to_sources(sources, skip, limit) + + return MultilingualSearchResponse( + query=query, + search_type=search_type, + sources=paginated_sources, + skip=skip, + limit=limit, + total=limit, + ) + + except Exception: + logger.exception("Error in v2 multilingual search") + raise diff --git a/pecha_api/search/search_openpecha_views.py b/pecha_api/search/search_openpecha_views.py new file mode 100644 index 000000000..f2a19d3fd --- /dev/null +++ b/pecha_api/search/search_openpecha_views.py @@ -0,0 +1,32 @@ +from typing import Optional + +from fastapi import APIRouter, Query +from starlette import status + +from .search_enums import V2ContentSearchType +from .search_openpecha_service import get_v2_multilingual_search_results +from .search_response_models import MultilingualSearchResponse + +search_v2_router = APIRouter( + prefix="/v2/search", + tags=["search-v2"], +) + + +@search_v2_router.get("/multilingual", status_code=status.HTTP_200_OK) +async def multilingual_search_v2( + query: str = Query(...), + search_type: V2ContentSearchType = Query(default=V2ContentSearchType.SIMILAR), + text_id: Optional[str] = Query(default=None), + edition_id: Optional[str] = Query(default=None), + skip: int = Query(default=0, ge=0), + limit: int = Query(default=10, ge=1, le=100), +) -> MultilingualSearchResponse: + return await get_v2_multilingual_search_results( + query=query, + search_type=search_type.value, + text_id=text_id, + edition_id=edition_id, + skip=skip, + limit=limit, + ) From e158d3aa8ab4c86755273e971a63d35a63ab8611 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Tue, 9 Jun 2026 12:49:08 +0530 Subject: [PATCH 29/29] refactor(search): update search router prefix and change search type enum --- pecha_api/app.py | 2 - pecha_api/search/search_enums.py | 6 - pecha_api/search/search_openpecha_service.py | 4 +- pecha_api/search/search_openpecha_views.py | 32 - pecha_api/search/search_service.py | 184 ----- pecha_api/search/search_views.py | 31 +- tests/search/test_search_service.py | 668 +------------------ tests/search/test_search_views.py | 69 +- 8 files changed, 34 insertions(+), 962 deletions(-) delete mode 100644 pecha_api/search/search_openpecha_views.py diff --git a/pecha_api/app.py b/pecha_api/app.py index 956a8b968..48b9616e4 100644 --- a/pecha_api/app.py +++ b/pecha_api/app.py @@ -12,7 +12,6 @@ from pecha_api.texts.groups import groups_views from pecha_api.share import share_views from pecha_api.search import search_views -from pecha_api.search import search_openpecha_views from pecha_api.plans.auth import plan_auth_views from pecha_api.plans.cms import cms_plans_views as cms_plans_views from pecha_api.plans.series import series_view as cms_series_views @@ -53,7 +52,6 @@ api.include_router(topics_views.topics_router) api.include_router(users_views.user_router) api.include_router(search_views.search_router) -api.include_router(search_openpecha_views.search_v2_router) api.include_router(share_views.share_router) api.include_router(plan_auth_views.plan_auth_router) api.include_router(cms_plans_views.cms_plans_router) diff --git a/pecha_api/search/search_enums.py b/pecha_api/search/search_enums.py index d22f22686..7e59ab93f 100644 --- a/pecha_api/search/search_enums.py +++ b/pecha_api/search/search_enums.py @@ -12,11 +12,5 @@ class QueryType(Enum): BOOL = "bool" # Boolean query with exact matching class MultilingualSearchType(Enum): - HYBRID = "hybrid" # Combined semantic + keyword search - BM25 = "bm25" # Keyword-based ranking algorithm - SEMANTIC = "semantic" # Vector similarity search - EXACT = "exact" # Exact text matching - -class V2ContentSearchType(Enum): EXACT = "exact" SIMILAR = "similar" diff --git a/pecha_api/search/search_openpecha_service.py b/pecha_api/search/search_openpecha_service.py index 9f66f8d00..b2cd88cb0 100644 --- a/pecha_api/search/search_openpecha_service.py +++ b/pecha_api/search/search_openpecha_service.py @@ -139,7 +139,7 @@ async def _build_sources_from_content_search_matches( return sources -async def get_v2_multilingual_search_results( +async def get_multilingual_search_results( query: str, search_type: str = "similar", text_id: Optional[str] = None, @@ -198,5 +198,5 @@ async def get_v2_multilingual_search_results( ) except Exception: - logger.exception("Error in v2 multilingual search") + logger.exception("Error in multilingual search") raise diff --git a/pecha_api/search/search_openpecha_views.py b/pecha_api/search/search_openpecha_views.py deleted file mode 100644 index f2a19d3fd..000000000 --- a/pecha_api/search/search_openpecha_views.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Optional - -from fastapi import APIRouter, Query -from starlette import status - -from .search_enums import V2ContentSearchType -from .search_openpecha_service import get_v2_multilingual_search_results -from .search_response_models import MultilingualSearchResponse - -search_v2_router = APIRouter( - prefix="/v2/search", - tags=["search-v2"], -) - - -@search_v2_router.get("/multilingual", status_code=status.HTTP_200_OK) -async def multilingual_search_v2( - query: str = Query(...), - search_type: V2ContentSearchType = Query(default=V2ContentSearchType.SIMILAR), - text_id: Optional[str] = Query(default=None), - edition_id: Optional[str] = Query(default=None), - skip: int = Query(default=0, ge=0), - limit: int = Query(default=10, ge=1, le=100), -) -> MultilingualSearchResponse: - return await get_v2_multilingual_search_results( - query=query, - search_type=search_type.value, - text_id=text_id, - edition_id=edition_id, - skip=skip, - limit=limit, - ) diff --git a/pecha_api/search/search_service.py b/pecha_api/search/search_service.py index 87c4285ce..b380125ed 100644 --- a/pecha_api/search/search_service.py +++ b/pecha_api/search/search_service.py @@ -2,7 +2,6 @@ from fastapi import HTTPException from starlette import status -from pecha_api.plans.response_message import NO_SEGMENTATION_IDS_RETURNED from .search_enums import SearchType from .search_client import search_client from pecha_api.config import get @@ -10,8 +9,6 @@ from pecha_api.texts.segments.segments_models import Segment from pecha_api.texts.texts_models import Text -from pecha_api.http_message_utils import handle_http_status_error, handle_request_error -import httpx import logging from .search_response_models import ( SearchResponse, @@ -20,8 +17,6 @@ SourceResultItem, Search, SheetResultItem, - ExternalSearchResponse, - MultilingualSegmentMatch, MultilingualSourceResult, MultilingualSearchResponse, SegmentLinkResponse, @@ -182,43 +177,6 @@ def _sheet_search(query: str, skip: int, limit: int) -> SearchResponse: -def build_search_filter(title: Optional[str] = None) -> Dict[str, List[str]]: - filter_obj = {} - - if title: - filter_obj["title"] = [title] - - return filter_obj - - -def build_search_payload( - query: str, - search_type: str, - limit: int, - title: Optional[str] = None -) -> Dict: - payload = { - "query": query, - "search_type": search_type, - "limit": limit, - "return_text": False, - } - - if title: - payload["filter"] = build_search_filter(title) - - return payload - - -def group_segments_by_text(segments: List[Segment]) -> Dict[str, List[Segment]]: - text_segments_map: Dict[str, List[Segment]] = {} - for segment in segments: - if segment.text_id not in text_segments_map: - text_segments_map[segment.text_id] = [] - text_segments_map[segment.text_id].append(segment) - return text_segments_map - - async def fetch_text_info(text_ids: List[str]) -> Dict[str, TextIndex]: text_info_map: Dict[str, TextIndex] = {} for text_id in text_ids: @@ -233,49 +191,6 @@ async def fetch_text_info(text_ids: List[str]) -> Dict[str, TextIndex]: return text_info_map -def build_segment_matches(segments: List[Segment], results_map: Dict[str, Dict]) -> List[MultilingualSegmentMatch]: - segment_matches = [] - for segment in segments: - pecha_id = segment.pecha_segment_id - if pecha_id and pecha_id in results_map: - segment_matches.append( - MultilingualSegmentMatch( - segment_id=str(segment.id), - content=segment.content, - relevance_score=results_map[pecha_id]["distance"], - pecha_segment_id=pecha_id - ) - ) - segment_matches.sort(key=lambda x: x.relevance_score) - return segment_matches - - -async def get_text_title_by_id(text_id: Optional[str]) -> Optional[str]: - if not text_id: - return None - - text = await Text.get_text(text_id) - if text: - return text.title - - return None - - -def build_results_map(external_results: ExternalSearchResponse) -> tuple[List[str], Dict[str, Dict]]: - segmentation_ids = [] - results_map = {} - - for result in external_results.results: - if result.id: - segmentation_ids.append(result.id) - results_map[result.id] = { - "distance": result.distance, - "content": result.entity.text if result.entity.text else "" - } - - return segmentation_ids, results_map - - def create_empty_search_response( query: str, search_type: str, @@ -337,105 +252,6 @@ async def fetch_segments_by_ids( return segments -async def get_multilingual_search_results( - query: str, - search_type: str = "hybrid", - text_id: Optional[str] = None, - skip: int = 0, - limit: int = 10 -) -> MultilingualSearchResponse: - try: - title = await get_text_title_by_id(text_id) - - external_limit = min(limit * 5, 100) - - external_results = await call_external_search_api( - query=query, - search_type=search_type, - limit=external_limit, - title=title - ) - - segmentation_ids, results_map = build_results_map(external_results) - - if not segmentation_ids: - logger.info(NO_SEGMENTATION_IDS_RETURNED) - return create_empty_search_response(query, search_type, skip, limit) - - segments = await fetch_segments_by_ids(segmentation_ids, text_id) - - if not segments: - return create_empty_search_response(query, search_type, skip, limit) - - final_display_sources = await build_multilingual_sources(segments, results_map) - - paginated_sources = apply_pagination_to_sources(final_display_sources, skip, limit) - - return MultilingualSearchResponse( - query=query, - search_type=search_type, - sources=paginated_sources, - skip=skip, - limit=limit, - total=limit - ) - - except Exception as e: - logger.error(f"Error in multilingual search: {str(e)}", exc_info=True) - raise - - -async def call_external_search_api( - query: str, - search_type: str = "hybrid", - limit: int = 10, - title: Optional[str] = None -) -> ExternalSearchResponse: - - external_api_url = "https://search.buddhistai.tools" - endpoint = f"{external_api_url}/search" - - payload = build_search_payload(query, search_type, limit, title) - print(payload) - try: - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - endpoint, - json=payload, - headers={"Content-Type": "application/json"} - ) - response.raise_for_status() - - data = response.json() - return ExternalSearchResponse(**data) - - except httpx.HTTPStatusError as e: - handle_http_status_error(e) - except httpx.RequestError as e: - handle_request_error(e) - - - -async def build_multilingual_sources(segments: List[Segment], results_map: Dict[str, Dict]) -> List[MultilingualSourceResult]: - text_segments_map = group_segments_by_text(segments) - text_info_map = await fetch_text_info(list(text_segments_map.keys())) - - sources = [] - for text_id, text_segments in text_segments_map.items(): - if text_id not in text_info_map: - continue - - segment_matches = build_segment_matches(text_segments, results_map) - - sources.append( - MultilingualSourceResult( - text=text_info_map[text_id], - segment_matches=segment_matches - ) - ) - - return sources - async def get_url_link(pecha_segment_id: str) -> SegmentLinkResponse: try: segment = await Segment.get_segment_by_pecha_segment_id(pecha_segment_id=pecha_segment_id) diff --git a/pecha_api/search/search_views.py b/pecha_api/search/search_views.py index 67dabbe5a..c98e77568 100644 --- a/pecha_api/search/search_views.py +++ b/pecha_api/search/search_views.py @@ -1,15 +1,14 @@ +from typing import Optional + from fastapi import APIRouter, Query -from .search_enums import SearchType, MultilingualSearchType from starlette import status -from typing import Optional - +from .search_enums import SearchType, MultilingualSearchType +from .search_openpecha_service import get_multilingual_search_results from .search_service import ( get_search_results, - get_multilingual_search_results, - get_url_link as get_url_link_service + get_url_link as get_url_link_service, ) - from .search_response_models import ( SearchResponse, MultilingualSearchResponse, @@ -18,42 +17,46 @@ search_router = APIRouter( prefix="/search", - tags=["Search"] + tags=["Search"], ) + @search_router.get("", status_code=status.HTTP_200_OK) async def search( query: str = Query(default=None, description="Search query"), search_type: SearchType = Query(default=None, description="Search type (SOURCE / SHEET)"), text_id: Optional[str] = Query(default=None, description="Text ID where the search is to be performed"), skip: int = Query(default=0), - limit: int = Query(default=10) + limit: int = Query(default=10), ) -> SearchResponse: return await get_search_results( query=query, search_type=search_type, text_id=text_id, skip=skip, - limit=limit + limit=limit, ) + @search_router.get("/multilingual", status_code=status.HTTP_200_OK) async def multilingual_search( query: str = Query(...), - search_type: MultilingualSearchType = Query(default=MultilingualSearchType.HYBRID), + search_type: MultilingualSearchType = Query(default=MultilingualSearchType.SIMILAR), text_id: Optional[str] = Query(default=None), + edition_id: Optional[str] = Query(default=None), skip: int = Query(default=0, ge=0), - limit: int = Query(default=10, ge=1, le=100) + limit: int = Query(default=10, ge=1, le=100), ) -> MultilingualSearchResponse: - return await get_multilingual_search_results( query=query, search_type=search_type.value, text_id=text_id, + edition_id=edition_id, skip=skip, - limit=limit + limit=limit, ) + @search_router.get("/chat/{pecha_segment_id}", status_code=status.HTTP_200_OK) async def get_url_link(pecha_segment_id: str) -> SegmentLinkResponse: - return await get_url_link_service(pecha_segment_id) \ No newline at end of file + return await get_url_link_service(pecha_segment_id) diff --git a/tests/search/test_search_service.py b/tests/search/test_search_service.py index 7353d70bb..02b971f28 100644 --- a/tests/search/test_search_service.py +++ b/tests/search/test_search_service.py @@ -2,7 +2,6 @@ from unittest.mock import patch, AsyncMock, Mock, MagicMock from uuid import uuid4 from fastapi import HTTPException -import httpx from pecha_api.search.search_response_models import ( SearchResponse, @@ -11,19 +10,12 @@ TextIndex, SegmentMatch, SearchType, - MultilingualSearchResponse, - MultilingualSourceResult, MultilingualSegmentMatch, - ExternalSearchResponse, - ExternalSearchResult, - ExternalSegmentEntity + MultilingualSourceResult, ) from pecha_api.search.search_service import ( get_search_results, - get_multilingual_search_results, - call_external_search_api, - build_multilingual_sources, - apply_pagination_to_sources + apply_pagination_to_sources, ) @pytest.mark.asyncio @@ -191,569 +183,6 @@ def _get_mock_elastic_source_within_text_response_(): } } -@pytest.mark.asyncio -async def test_get_multilingual_search_results_success(): - """Test successful multilingual search with all components""" - - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[ - ExternalSearchResult( - id="pecha_seg_1", - distance=0.9, - entity=ExternalSegmentEntity(text="Content 1") - ), - ExternalSearchResult( - id="pecha_seg_2", - distance=0.8, - entity=ExternalSegmentEntity(text="Content 2") - ) - ], - count=2 - ) - - mock_segment_1 = Mock() - mock_segment_1.id = uuid4() - mock_segment_1.content = "Content 1" - mock_segment_1.text_id = "text_123" - mock_segment_1.pecha_segment_id = "pecha_seg_1" - - mock_segment_2 = Mock() - mock_segment_2.id = uuid4() - mock_segment_2.content = "Content 2" - mock_segment_2.text_id = "text_123" - mock_segment_2.pecha_segment_id = "pecha_seg_2" - - mock_segments = [mock_segment_1, mock_segment_2] - - mock_text = Mock() - mock_text.title = "Test Text" - mock_text.language = "bo" - mock_text.created_at = "2024-01-01" - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \ - patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=mock_segments), \ - patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text): - - response = await get_multilingual_search_results( - query="test query", - search_type="hybrid", - skip=0, - limit=10 - ) - - assert response is not None - assert isinstance(response, MultilingualSearchResponse) - assert response.query == "test query" - assert response.search_type == "hybrid" - assert len(response.sources) == 1 - assert response.sources[0].text.text_id == "text_123" - assert len(response.sources[0].segment_matches) == 2 - assert response.total == 10 - - -@pytest.mark.asyncio -async def test_get_multilingual_search_results_with_text_id(): - """Test multilingual search with specific text_id""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[ - ExternalSearchResult( - id="pecha_seg_1", - distance=0.9, - entity=ExternalSegmentEntity(text="Content 1") - ) - ], - count=1 - ) - - mock_segment = Mock() - mock_segment.id = uuid4() - mock_segment.content = "Content 1" - mock_segment.text_id = "specific_text_123" - mock_segment.pecha_segment_id = "pecha_seg_1" - - mock_text = Mock() - mock_text.title = "Specific Text" - mock_text.language = "en" - mock_text.created_at = "2024-01-01" - - with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text), \ - patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \ - patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=[mock_segment]): - - response = await get_multilingual_search_results( - query="test query", - text_id="specific_text_123", - skip=0, - limit=10 - ) - - assert response is not None - assert len(response.sources) == 1 - assert response.sources[0].text.text_id == "specific_text_123" - - -@pytest.mark.asyncio -async def test_get_multilingual_search_results_no_external_results(): - """Test multilingual search when external API returns no results""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[], - count=0 - ) - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response): - - response = await get_multilingual_search_results( - query="test query", - search_type="hybrid", - skip=0, - limit=10 - ) - - assert response is not None - assert response.sources == [] - assert response.total == 0 - - -@pytest.mark.asyncio -async def test_get_multilingual_search_results_no_segments_found(): - """Test multilingual search when no internal segments match external IDs""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[ - ExternalSearchResult( - id="pecha_seg_1", - distance=0.9, - entity=ExternalSegmentEntity(text="Content 1") - ) - ], - count=1 - ) - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \ - patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=[]): - - response = await get_multilingual_search_results( - query="test query", - search_type="hybrid", - skip=0, - limit=10 - ) - - assert response is not None - assert response.sources == [] - assert response.total == 0 - - -@pytest.mark.asyncio -async def test_get_multilingual_search_results_text_not_found(): - """Test multilingual search when text_id doesn't exist""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[ - ExternalSearchResult( - id="pecha_seg_1", - distance=0.9, - entity=ExternalSegmentEntity(text="Content 1") - ) - ], - count=1 - ) - - mock_segment = Mock() - mock_segment.id = uuid4() - mock_segment.content = "Content 1" - mock_segment.text_id = "text_123" - mock_segment.pecha_segment_id = "pecha_seg_1" - - mock_text = Mock() - mock_text.title = "Test Text" - mock_text.language = "bo" - mock_text.created_at = "2024-01-01" - - with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, side_effect=[None, mock_text]), \ - patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \ - patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=[mock_segment]): - - response = await get_multilingual_search_results( - query="test query", - text_id="nonexistent_text", - skip=0, - limit=10 - ) - - assert response is not None - assert len(response.sources) == 1 - - -@pytest.mark.asyncio -async def test_get_multilingual_search_results_with_text_id(): - """Test multilingual search with text_id parameter""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[], - count=0 - ) - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response) as mock_call, \ - patch("pecha_api.search.search_service.get_text_title_by_id", new_callable=AsyncMock, return_value="Test Title"): - - await get_multilingual_search_results( - query="test query", - text_id="text_123", - skip=0, - limit=10 - ) - - mock_call.assert_called_once() - call_args = mock_call.call_args - assert call_args.kwargs["title"] == "Test Title" - - -@pytest.mark.asyncio -async def test_get_multilingual_search_results_different_search_types(): - """Test multilingual search with different search types""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="semantic", - results=[], - count=0 - ) - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response): - - response = await get_multilingual_search_results( - query="test query", - search_type="semantic", - skip=0, - limit=10 - ) - - assert response.search_type == "semantic" - - -@pytest.mark.asyncio -async def test_call_external_search_api_success(): - """Test successful call to external search API""" - mock_response_data = { - "query": "test query", - "search_type": "hybrid", - "results": [ - { - "id": "pecha_seg_1", - "distance": 0.9, - "entity": {"text": "Content 1"} - } - ], - "count": 1 - } - - mock_http_response = Mock() - mock_http_response.json.return_value = mock_response_data - mock_http_response.raise_for_status = Mock() - - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=mock_http_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("httpx.AsyncClient", return_value=mock_client): - - response = await call_external_search_api( - query="test query", - search_type="hybrid", - limit=10 - ) - - assert response is not None - assert isinstance(response, ExternalSearchResponse) - assert response.query == "test query" - assert len(response.results) == 1 - - -@pytest.mark.asyncio -async def test_call_external_search_api_without_filters(): - """Test external API call without any filters""" - mock_response_data = { - "query": "test query", - "search_type": "hybrid", - "results": [], - "count": 0 - } - - mock_http_response = Mock() - mock_http_response.json.return_value = mock_response_data - mock_http_response.raise_for_status = Mock() - - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=mock_http_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("httpx.AsyncClient", return_value=mock_client): - - await call_external_search_api( - query="test query", - limit=10 - ) - - call_args = mock_client.post.call_args - payload = call_args.kwargs["json"] - assert "filter" not in payload - - -@pytest.mark.asyncio -async def test_call_external_search_api_with_title(): - """Test external API call with title filter""" - mock_response_data = { - "query": "test query", - "search_type": "hybrid", - "results": [], - "count": 0 - } - - mock_http_response = Mock() - mock_http_response.json.return_value = mock_response_data - mock_http_response.raise_for_status = Mock() - - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=mock_http_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("httpx.AsyncClient", return_value=mock_client): - - await call_external_search_api( - query="test query", - title="Specific Title", - limit=10 - ) - - call_args = mock_client.post.call_args - payload = call_args.kwargs["json"] - assert "filter" in payload - assert "title" in payload["filter"] - assert payload["filter"]["title"] == ["Specific Title"] - - -@pytest.mark.asyncio -async def test_call_external_search_api_http_error(): - """Test external API call when HTTP error occurs""" - mock_http_response = Mock() - mock_http_response.status_code = 500 - mock_http_response.text = "Internal Server Error" - - mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.HTTPStatusError( - "Server error", - request=Mock(), - response=mock_http_response - )) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("httpx.AsyncClient", return_value=mock_client): - - with pytest.raises(HTTPException) as exc_info: - await call_external_search_api( - query="test query", - limit=10 - ) - - assert exc_info.value.status_code == 500 - - -@pytest.mark.asyncio -async def test_call_external_search_api_request_error(): - """Test external API call when request fails""" - mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.RequestError("Connection failed")) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch("httpx.AsyncClient", return_value=mock_client): - - with pytest.raises(HTTPException) as exc_info: - await call_external_search_api( - query="test query", - limit=10 - ) - - assert exc_info.value.status_code == 500 - assert "Failed to connect" in exc_info.value.detail - - -@pytest.mark.asyncio -async def test_build_multilingual_sources_success(): - """Test building multilingual sources from segments""" - - mock_segment_1 = Mock() - mock_segment_1.id = uuid4() - mock_segment_1.content = "Content 1" - mock_segment_1.text_id = "text_123" - mock_segment_1.pecha_segment_id = "pecha_seg_1" - - mock_segment_2 = Mock() - mock_segment_2.id = uuid4() - mock_segment_2.content = "Content 2" - mock_segment_2.text_id = "text_123" - mock_segment_2.pecha_segment_id = "pecha_seg_2" - - segments = [mock_segment_1, mock_segment_2] - - results_map = { - "pecha_seg_1": {"distance": 0.9, "content": "Content 1"}, - "pecha_seg_2": {"distance": 0.8, "content": "Content 2"} - } - - mock_text = Mock() - mock_text.title = "Test Text" - mock_text.language = "bo" - mock_text.created_at = "2024-01-01" - - with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text): - - final_display_sources = await build_multilingual_sources(segments, results_map) - - assert len(final_display_sources) == 1 - assert isinstance(final_display_sources[0], MultilingualSourceResult) - assert final_display_sources[0].text.text_id == "text_123" - assert len(final_display_sources[0].segment_matches) == 2 - assert final_display_sources[0].segment_matches[0].relevance_score <= final_display_sources[0].segment_matches[1].relevance_score - - -@pytest.mark.asyncio -async def test_build_multilingual_sources_multiple_texts(): - """Test building sources from segments across multiple texts""" - mock_segment_1 = Mock() - mock_segment_1.id = uuid4() - mock_segment_1.content = "Content 1" - mock_segment_1.text_id = "text_123" - mock_segment_1.pecha_segment_id = "pecha_seg_1" - - mock_segment_2 = Mock() - mock_segment_2.id = uuid4() - mock_segment_2.content = "Content 2" - mock_segment_2.text_id = "text_456" - mock_segment_2.pecha_segment_id = "pecha_seg_2" - - segments = [mock_segment_1, mock_segment_2] - - results_map = { - "pecha_seg_1": {"distance": 0.9, "content": "Content 1"}, - "pecha_seg_2": {"distance": 0.8, "content": "Content 2"} - } - - mock_text_1 = Mock() - mock_text_1.title = "Test Text 1" - mock_text_1.language = "bo" - mock_text_1.created_at = "2024-01-01" - - mock_text_2 = Mock() - mock_text_2.title = "Test Text 2" - mock_text_2.language = "en" - mock_text_2.created_at = "2024-01-02" - - with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, side_effect=[mock_text_1, mock_text_2]): - - final_display_sources = await build_multilingual_sources(segments, results_map) - - assert len(final_display_sources) == 2 - assert final_display_sources[0].text.text_id == "text_123" - assert final_display_sources[1].text.text_id == "text_456" - - -@pytest.mark.asyncio -async def test_build_multilingual_sources_missing_text(): - """Test building sources when text is not found""" - mock_segment = Mock() - mock_segment.id = uuid4() - mock_segment.content = "Content 1" - mock_segment.text_id = "text_123" - mock_segment.pecha_segment_id = "pecha_seg_1" - - segments = [mock_segment] - results_map = { - "pecha_seg_1": {"distance": 0.9, "content": "Content 1"} - } - - with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=None): - - final_display_sources = await build_multilingual_sources(segments, results_map) - - assert len(final_display_sources) == 0 - - -@pytest.mark.asyncio -async def test_build_multilingual_sources_segment_not_in_results_map(): - """Test building sources when segment pecha_id not in results map""" - mock_segment = Mock() - mock_segment.id = uuid4() - mock_segment.content = "Content 1" - mock_segment.text_id = "text_123" - mock_segment.pecha_segment_id = "pecha_seg_unknown" - - segments = [mock_segment] - results_map = { - "pecha_seg_1": {"distance": 0.9, "content": "Content 1"} - } - - mock_text = Mock() - mock_text.title = "Test Text" - mock_text.language = "bo" - mock_text.created_at = "2024-01-01" - - with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text): - - final_display_sources = await build_multilingual_sources(segments, results_map) - - assert len(final_display_sources) == 1 - assert len(final_display_sources[0].segment_matches) == 0 - - -@pytest.mark.asyncio -async def test_build_multilingual_sources_sorting(): - """Test that segment matches are sorted by relevance score""" - segments = [] - results_map = {} - - for i in range(5): - mock_segment = Mock() - mock_segment.id = uuid4() - mock_segment.content = f"Content {i}" - mock_segment.text_id = "text_123" - mock_segment.pecha_segment_id = f"pecha_seg_{i}" - segments.append(mock_segment) - - results_map[f"pecha_seg_{i}"] = { - "distance": 0.5 + (i * 0.1), - "content": f"Content {i}" - } - - mock_text = Mock() - mock_text.title = "Test Text" - mock_text.language = "bo" - mock_text.created_at = "2024-01-01" - - with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text): - - final_display_sources = await build_multilingual_sources(segments, results_map) - - assert len(final_display_sources) == 1 - assert len(final_display_sources[0].segment_matches) == 5 - - scores = [match.relevance_score for match in final_display_sources[0].segment_matches] - assert scores == sorted(scores) - - @pytest.mark.asyncio async def test_get_url_link_success(): """Test get_url_link service with valid pecha_segment_id""" @@ -927,99 +356,6 @@ async def test_get_url_link_none_segment_id(): assert result.text_id == "text123" assert result.segment_id == "None" -@pytest.mark.asyncio -async def test_get_multilingual_search_external_limit_calculation(): - """Test that external API is called with limit * 5, max 100""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[], - count=0 - ) - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response) as mock_call: - - await get_multilingual_search_results( - query="test query", - search_type="hybrid", - skip=0, - limit=10 - ) - - call_args = mock_call.call_args - assert call_args.kwargs["limit"] == 50 # 10 * 5 = 50 - - -@pytest.mark.asyncio -async def test_get_multilingual_search_external_limit_max_cap(): - """Test that external API limit is capped at 100""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[], - count=0 - ) - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response) as mock_call: - - await get_multilingual_search_results( - query="test query", - search_type="hybrid", - skip=0, - limit=30 - ) - - call_args = mock_call.call_args - assert call_args.kwargs["limit"] == 100 # min(30 * 5, 100) = 100 - - -@pytest.mark.asyncio -async def test_get_multilingual_search_total_returns_limit(): - """Test that total returns the set limit value""" - mock_external_response = ExternalSearchResponse( - query="test query", - search_type="hybrid", - results=[ - ExternalSearchResult(id="pecha_seg_1", distance=0.9, entity=ExternalSegmentEntity(text="Content 1")), - ExternalSearchResult(id="pecha_seg_2", distance=0.8, entity=ExternalSegmentEntity(text="Content 2")), - ExternalSearchResult(id="pecha_seg_3", distance=0.7, entity=ExternalSegmentEntity(text="Content 3")), - ], - count=100 - ) - - mock_segment_1 = Mock() - mock_segment_1.id = uuid4() - mock_segment_1.content = "Content 1" - mock_segment_1.text_id = "text_123" - mock_segment_1.pecha_segment_id = "pecha_seg_1" - - mock_segment_2 = Mock() - mock_segment_2.id = uuid4() - mock_segment_2.content = "Content 2" - mock_segment_2.text_id = "text_123" - mock_segment_2.pecha_segment_id = "pecha_seg_2" - - mock_segments = [mock_segment_1, mock_segment_2] - - mock_text = Mock() - mock_text.title = "Test Text" - mock_text.language = "bo" - mock_text.created_at = "2024-01-01" - - with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \ - patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=mock_segments), \ - patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text): - - response = await get_multilingual_search_results( - query="test query", - search_type="hybrid", - skip=0, - limit=10 - ) - - assert response.total == 10 - - def test_apply_pagination_to_sources_basic(): """Test basic pagination with skip and limit""" text_info = TextIndex(text_id="text_123", language="bo", title="Test", published_date="") diff --git a/tests/search/test_search_views.py b/tests/search/test_search_views.py index f4c10ccfe..a96034191 100644 --- a/tests/search/test_search_views.py +++ b/tests/search/test_search_views.py @@ -308,8 +308,8 @@ def test_search_service_error(): assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR -def test_multilingual_search_success_hybrid(): - """Test multilingual search with HYBRID search type (default)""" +def test_multilingual_search_success_similar(): + """Test multilingual search with SIMILAR search type (default)""" mock_segment_matches = [ MultilingualSegmentMatch( segment_id=f"seg_{i}", @@ -334,7 +334,7 @@ def test_multilingual_search_success_hybrid(): mock_response = MultilingualSearchResponse( query="test query", - search_type="hybrid", + search_type="similar", sources=mock_source_results, skip=0, limit=10, @@ -350,7 +350,7 @@ def test_multilingual_search_success_hybrid(): data = response.json() assert data["query"] == "test query" - assert data["search_type"] == "hybrid" + assert data["search_type"] == "similar" assert len(data["sources"]) == 1 assert data["sources"][0]["text"]["text_id"] == "text_123" assert data["sources"][0]["text"]["language"] == "bo" @@ -360,49 +360,6 @@ def test_multilingual_search_success_hybrid(): assert data["total"] == 1 -def test_multilingual_search_with_semantic_type(): - """Test multilingual search with SEMANTIC search type""" - mock_response = MultilingualSearchResponse( - query="semantic query", - search_type="semantic", - sources=[], - skip=0, - limit=10, - total=0 - ) - - with patch("pecha_api.search.search_views.get_multilingual_search_results", - new_callable=AsyncMock, return_value=mock_response): - - response = client.get("/search/multilingual?query=semantic query&search_type=semantic") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["search_type"] == "semantic" - assert data["sources"] == [] - - -def test_multilingual_search_with_bm25_type(): - """Test multilingual search with BM25 search type""" - mock_response = MultilingualSearchResponse( - query="bm25 query", - search_type="bm25", - sources=[], - skip=0, - limit=10, - total=0 - ) - - with patch("pecha_api.search.search_views.get_multilingual_search_results", - new_callable=AsyncMock, return_value=mock_response): - - response = client.get("/search/multilingual?query=bm25 query&search_type=bm25") - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["search_type"] == "bm25" - - def test_multilingual_search_with_exact_type(): """Test multilingual search with EXACT search type""" mock_response = MultilingualSearchResponse( @@ -428,7 +385,7 @@ def test_multilingual_search_with_text_id(): """Test multilingual search with specific text_id parameter""" mock_response = MultilingualSearchResponse( query="query in specific text", - search_type="hybrid", + search_type="similar", sources=[], skip=0, limit=10, @@ -447,7 +404,7 @@ def test_multilingual_search_with_search_type(): """Test multilingual search with search_type parameter""" mock_response = MultilingualSearchResponse( query="query with search type", - search_type="semantic", + search_type="similar", sources=[], skip=0, limit=10, @@ -457,7 +414,7 @@ def test_multilingual_search_with_search_type(): with patch("pecha_api.search.search_views.get_multilingual_search_results", new_callable=AsyncMock, return_value=mock_response): - response = client.get("/search/multilingual?query=query with search type&search_type=semantic") + response = client.get("/search/multilingual?query=query with search type&search_type=similar") assert response.status_code == status.HTTP_200_OK @@ -466,7 +423,7 @@ def test_multilingual_search_with_pagination(): """Test multilingual search with custom pagination""" mock_response = MultilingualSearchResponse( query="paginated query", - search_type="hybrid", + search_type="similar", sources=[], skip=20, limit=50, @@ -489,7 +446,7 @@ def test_multilingual_search_with_all_parameters(): """Test multilingual search with all optional parameters""" mock_response = MultilingualSearchResponse( query="full query", - search_type="semantic", + search_type="exact", sources=[], skip=10, limit=25, @@ -500,12 +457,12 @@ def test_multilingual_search_with_all_parameters(): new_callable=AsyncMock, return_value=mock_response): response = client.get( - "/search/multilingual?query=full query&search_type=semantic&text_id=text_456&skip=10&limit=25" + "/search/multilingual?query=full query&search_type=exact&text_id=text_456&edition_id=edition_789&skip=10&limit=25" ) assert response.status_code == status.HTTP_200_OK data = response.json() - assert data["search_type"] == "semantic" + assert data["search_type"] == "exact" assert data["skip"] == 10 assert data["limit"] == 25 @@ -514,7 +471,7 @@ def test_multilingual_search_empty_results(): """Test multilingual search with no results found""" mock_response = MultilingualSearchResponse( query="no results query", - search_type="hybrid", + search_type="similar", sources=[], skip=0, limit=10, @@ -556,7 +513,7 @@ def test_multilingual_search_multiple_sources(): mock_response = MultilingualSearchResponse( query="multi source query", - search_type="hybrid", + search_type="similar", sources=mock_sources, skip=0, limit=10,