import os import unittest from unittest.mock import Mock, patch from app.features.memoir.helpers import chapter_to_dict as _chapter_to_dict from app.features.memoir.memoir_images.storage import CosDownloadUrlError def _image_stub(**kwargs): """构造具有 .placeholder 等属性的 image 对象,供 memoir_image_to_dict 使用。""" defaults = { "placeholder": "", "description": "", "order_index": 0, "status": "pending", "prompt": None, "url": None, "storage_key": None, "provider": None, "style": None, "size": None, "error": None, "retryable": None, "created_at": None, "updated_at": None, "section_id": None, } defaults.update(kwargs) return type("ImageStub", (), defaults)() def _chapter_stub(*, images=None, sections=None): """构造带 images/sections 的 chapter stub。images 为 image_stub 列表(section_id=None 的作封面),sections 为 section 列表(含 image_record)。""" images = images or [] sections = sections or [] return type( "ChapterStub", (), { "id": "chapter-1", "title": "童年的夏天", "content": "", "order_index": 0, "status": "completed", "category": "childhood", "images": images, "sections": sections, "cover_image": None, "updated_at": None, "is_new": False, "source_segments": [], }, )() class ChaptersRouterImagesTest(unittest.TestCase): @patch("app.features.memoir.router.TencentCosStorageService") @patch.dict( os.environ, { "TENCENT_COS_BUCKET": "life-echo-dev-1319381411", "TENCENT_COS_REGION": "ap-shanghai", "TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com", }, clear=False, ) def test_chapter_to_dict_returns_signed_image_urls_for_response(self, storage_cls): storage = Mock() storage.get_download_url.return_value = ( "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123" ) storage_cls.from_settings.return_value = storage img0 = _image_stub( placeholder="{{IMAGE:南方小镇的青石板路}}", description="南方小镇的青石板路", status="completed", prompt="A serene southern China town", url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", storage_key="memoirs/u1/c1/0-demo.png", section_id=None, order_index=0, ) sec = type( "SectionStub", (), {"content": "", "order_index": 0, "image_record": img0, "image_id": None}, )() chapter = _chapter_stub(images=[img0], sections=[sec]) payload = _chapter_to_dict(chapter) self.assertEqual( payload["images"][0]["url"], "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123", ) self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town") self.assertNotIn("storage_key", payload["images"][0]) @patch("app.features.memoir.router.TencentCosStorageService") @patch.dict( os.environ, { "TENCENT_COS_BUCKET": "life-echo-dev-1319381411", "TENCENT_COS_REGION": "ap-shanghai", "TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com", }, clear=False, ) def test_chapter_to_dict_preserves_completed_asset_when_signing_fails( self, storage_cls ): storage = Mock() storage.get_download_url.side_effect = CosDownloadUrlError( "cos unavailable", retryable=True, request_id="req-err" ) storage_cls.from_settings.return_value = storage img0 = _image_stub( placeholder="{{IMAGE:南方小镇的青石板路}}", description="南方小镇的青石板路", status="completed", prompt="A serene southern China town", url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", storage_key="memoirs/u1/c1/0-demo.png", section_id=None, order_index=0, ) sec = type( "SectionStub", (), {"content": "", "order_index": 0, "image_record": img0, "image_id": None}, )() chapter = _chapter_stub(images=[img0], sections=[sec]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"][0]["status"], "completed") self.assertIsNone(payload["images"][0]["url"]) self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town") self.assertEqual(payload["images"][0]["error"], "image delivery unavailable") self.assertNotIn("storage_key", payload["images"][0]) @patch("app.features.memoir.router.TencentCosStorageService") def test_chapter_to_dict_drops_malformed_image_assets(self, storage_cls): storage_cls.from_settings.return_value = Mock() # 无 sections 时 content/images 来自 _sections_to_content_and_images 得到 [];无有效封面(images 的 section_id 非空) img = _image_stub( status="completed", placeholder="", description="", section_id="sec1" ) chapter = _chapter_stub(images=[img], sections=[]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"], []) @patch("app.features.memoir.router.MemoirImageSettings") @patch("app.features.memoir.router.TencentCosStorageService") def test_chapter_to_dict_hides_non_completed_assets_when_feature_disabled( self, storage_cls, memoir_img_settings_cls ): storage = Mock() storage.get_download_url.return_value = ( "https://signed.example.com/0.png?sig=123" ) storage_cls.from_settings.return_value = storage memoir_img_settings_cls.from_settings.return_value = Mock(enabled=False) # 仅一个 completed 的 section;enabled=False 时 completed_image_assets 只保留 completed,故仍为 1 条 img_completed = _image_stub( placeholder="{{IMAGE:奶奶坐在院子里的藤椅上}}", description="奶奶坐在院子里的藤椅上", status="completed", url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/1-demo.png", storage_key="memoirs/u1/c1/1-demo.png", order_index=0, section_id="s1", ) sec = type( "SectionStub", (), { "content": "", "order_index": 0, "image_record": img_completed, "image_id": None, }, )() chapter = _chapter_stub(images=[img_completed], sections=[sec]) payload = _chapter_to_dict(chapter) self.assertEqual(len(payload["images"]), 1) self.assertEqual(payload["images"][0]["status"], "completed") @patch("app.features.memoir.router.TencentCosStorageService") @patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False) def test_chapter_to_dict_preserves_retryable_flag_for_failed_assets( self, storage_cls ): storage_cls.from_settings.return_value = Mock() img = _image_stub( placeholder="{{IMAGE:南方小镇的青石板路}}", description="南方小镇的青石板路", status="failed", url=None, error="upload denied", retryable=False, order_index=0, ) sec = type( "SectionStub", (), {"content": "", "order_index": 0, "image_record": img, "image_id": None}, )() chapter = _chapter_stub(images=[img], sections=[sec]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"][0]["status"], "failed") self.assertFalse(payload["images"][0]["retryable"])