2026-03-11 10:06:12 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import unittest
|
|
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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": [],
|
|
|
|
|
|
},
|
|
|
|
|
|
)()
|
2026-03-11 10:06:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChaptersRouterImagesTest(unittest.TestCase):
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@patch("app.features.memoir.router.TencentCosStorageService")
|
2026-03-11 10:06:12 +08:00
|
|
|
|
@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"
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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])
|
2026-03-11 10:06:12 +08:00
|
|
|
|
|
|
|
|
|
|
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])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@patch("app.features.memoir.router.TencentCosStorageService")
|
2026-03-11 11:26:42 +08:00
|
|
|
|
@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()
|
2026-03-18 17:18:23 +08:00
|
|
|
|
storage.get_download_url.side_effect = CosDownloadUrlError(
|
2026-03-11 15:20:59 +08:00
|
|
|
|
"cos unavailable", retryable=True, request_id="req-err"
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
payload = _chapter_to_dict(chapter)
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(payload["images"][0]["status"], "completed")
|
2026-03-11 15:20:59 +08:00
|
|
|
|
self.assertIsNone(payload["images"][0]["url"])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town")
|
2026-03-11 15:20:59 +08:00
|
|
|
|
self.assertEqual(payload["images"][0]["error"], "image delivery unavailable")
|
2026-03-11 11:26:42 +08:00
|
|
|
|
self.assertNotIn("storage_key", payload["images"][0])
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@patch("app.features.memoir.router.TencentCosStorageService")
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def test_chapter_to_dict_drops_malformed_image_assets(self, storage_cls):
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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=[])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
payload = _chapter_to_dict(chapter)
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(payload["images"], [])
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@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):
|
2026-03-11 11:26:42 +08:00
|
|
|
|
storage = Mock()
|
|
|
|
|
|
storage.get_download_url.return_value = "https://signed.example.com/0.png?sig=123"
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
payload = _chapter_to_dict(chapter)
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(len(payload["images"]), 1)
|
|
|
|
|
|
self.assertEqual(payload["images"][0]["status"], "completed")
|
2026-03-11 15:20:59 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@patch("app.features.memoir.router.TencentCosStorageService")
|
2026-03-11 15:20:59 +08:00
|
|
|
|
@patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False)
|
|
|
|
|
|
def test_chapter_to_dict_preserves_retryable_flag_for_failed_assets(self, storage_cls):
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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])
|
2026-03-11 15:20:59 +08:00
|
|
|
|
|
|
|
|
|
|
payload = _chapter_to_dict(chapter)
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(payload["images"][0]["status"], "failed")
|
|
|
|
|
|
self.assertFalse(payload["images"][0]["retryable"])
|