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, } defaults.update(kwargs) return type("ImageStub", (), defaults)() def _chapter_stub(*, images=None, canonical_markdown="正文"): """stories-first:章节配图均为 chapter 级 MemoirImage(按 order_index 取封面)。""" images = images or [] return type( "ChapterStub", (), { "id": "chapter-1", "title": "童年的夏天", "content": "", "order_index": 0, "status": "completed", "category": "childhood", "canonical_markdown": canonical_markdown, "images": images, "cover_image": None, "cover_asset_id": None, "updated_at": None, "is_new": False, "source_segments": [], }, )() class ChaptersRouterImagesTest(unittest.TestCase): @patch("app.features.memoir.helpers.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", order_index=0, ) chapter = _chapter_stub(images=[img0]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"], []) self.assertEqual( payload["cover_image"]["url"], "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123", ) self.assertEqual( payload["cover_image"]["prompt"], "A serene southern China town" ) self.assertNotIn("storage_key", payload["cover_image"]) @patch("app.features.memoir.helpers.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", order_index=0, ) chapter = _chapter_stub(images=[img0]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"], []) self.assertEqual(payload["cover_image"]["status"], "completed") self.assertIsNone(payload["cover_image"]["url"]) self.assertEqual( payload["cover_image"]["prompt"], "A serene southern China town" ) self.assertEqual(payload["cover_image"]["error"], "image delivery unavailable") self.assertNotIn("storage_key", payload["cover_image"]) @patch("app.features.memoir.helpers.TencentCosStorageService") def test_chapter_to_dict_inline_images_list_empty(self, storage_cls): storage_cls.from_settings.return_value = Mock() img = _image_stub( status="completed", placeholder="", description="", order_index=0 ) chapter = _chapter_stub(images=[img]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"], []) @patch("app.features.memoir.helpers.MemoirImageSettings") @patch("app.features.memoir.helpers.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) 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, ) chapter = _chapter_stub(images=[img_completed]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"], []) self.assertEqual(payload["cover_image"]["status"], "completed") @patch("app.features.memoir.helpers.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, ) chapter = _chapter_stub(images=[img]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"], []) self.assertEqual(payload["cover_image"]["status"], "failed") self.assertFalse(payload["cover_image"]["retryable"])