2026-03-10 16:05:13 +08:00
|
|
|
|
import unittest
|
2026-03-11 11:26:42 +08:00
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
from types import SimpleNamespace
|
2026-03-10 16:05:13 +08:00
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
|
from api.tasks import memoir_tasks
|
2026-03-10 16:05:13 +08:00
|
|
|
|
from api.tasks.memoir_tasks import generate_chapter_images
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 11:12:10 +08:00
|
|
|
|
def _section_image_record(img_dict):
|
|
|
|
|
|
"""把图片 dict 转成 image_record 用的 SimpleNamespace(可被任务更新属性)。"""
|
|
|
|
|
|
d = dict(img_dict or {})
|
|
|
|
|
|
return SimpleNamespace(
|
2026-03-14 23:58:46 +08:00
|
|
|
|
order_index=d.get("index", 0),
|
2026-03-13 11:12:10 +08:00
|
|
|
|
placeholder=d.get("placeholder"),
|
|
|
|
|
|
description=d.get("description"),
|
|
|
|
|
|
status=d.get("status"),
|
|
|
|
|
|
prompt=d.get("prompt"),
|
|
|
|
|
|
url=d.get("url"),
|
|
|
|
|
|
storage_key=d.get("storage_key"),
|
2026-03-14 23:58:46 +08:00
|
|
|
|
provider=d.get("provider"),
|
|
|
|
|
|
style=d.get("style"),
|
|
|
|
|
|
size=d.get("size"),
|
2026-03-13 11:12:10 +08:00
|
|
|
|
error=d.get("error"),
|
|
|
|
|
|
retryable=d.get("retryable"),
|
2026-03-14 23:58:46 +08:00
|
|
|
|
created_at=d.get("created_at"),
|
|
|
|
|
|
updated_at=d.get("updated_at"),
|
2026-03-13 11:12:10 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _chapter_with_sections(sections_data):
|
|
|
|
|
|
"""构造带 sections 的 chapter stub,供 generate_chapter_images 使用(任务从 section.image_record 读/写)。"""
|
|
|
|
|
|
sections = []
|
|
|
|
|
|
for i, d in enumerate(sections_data):
|
|
|
|
|
|
img = d.get("image")
|
|
|
|
|
|
if img:
|
|
|
|
|
|
rec = _section_image_record(img)
|
|
|
|
|
|
sec = SimpleNamespace(
|
|
|
|
|
|
content=d.get("content", ""),
|
|
|
|
|
|
image_id="img-%s-%s" % (i, id(rec)),
|
|
|
|
|
|
image_record=rec,
|
|
|
|
|
|
order_index=d.get("order_index", i),
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
sec = SimpleNamespace(
|
|
|
|
|
|
content=d.get("content", ""),
|
|
|
|
|
|
image_id=None,
|
|
|
|
|
|
image_record=None,
|
|
|
|
|
|
order_index=d.get("order_index", i),
|
|
|
|
|
|
)
|
|
|
|
|
|
sections.append(sec)
|
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
|
id="chapter-1",
|
|
|
|
|
|
user_id="user-1",
|
|
|
|
|
|
title="童年的夏天",
|
|
|
|
|
|
category="childhood",
|
|
|
|
|
|
cover_image=None,
|
|
|
|
|
|
images=[],
|
|
|
|
|
|
sections=sections,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bind_db_execute_to_chapter(db_mock, chapter):
|
|
|
|
|
|
"""让 db.execute(select(...)).unique().scalar_one_or_none() 返回 chapter。"""
|
|
|
|
|
|
db_mock.execute.return_value.unique.return_value.scalar_one_or_none.return_value = chapter
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 16:05:13 +08:00
|
|
|
|
class GenerateChapterImagesTaskTest(unittest.TestCase):
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def setUp(self):
|
|
|
|
|
|
memoir_tasks._REDIS_CLIENTS.clear()
|
|
|
|
|
|
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.redis.from_url")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.SessionLocal")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.TencentCosStorageService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.LiblibImageProvider")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImagePromptService")
|
|
|
|
|
|
def test_generate_chapter_images_skips_when_lock_is_already_held(
|
|
|
|
|
|
self,
|
|
|
|
|
|
prompt_service_cls,
|
|
|
|
|
|
provider_cls,
|
|
|
|
|
|
storage_cls,
|
|
|
|
|
|
session_local_cls,
|
|
|
|
|
|
redis_from_url,
|
|
|
|
|
|
):
|
2026-03-13 11:12:10 +08:00
|
|
|
|
chapter = _chapter_with_sections([
|
|
|
|
|
|
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
|
|
|
|
|
])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
db = Mock()
|
2026-03-13 11:12:10 +08:00
|
|
|
|
_bind_db_execute_to_chapter(db, chapter)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
session_local_cls.return_value = db
|
|
|
|
|
|
redis_from_url.return_value.set.return_value = False
|
|
|
|
|
|
|
|
|
|
|
|
result = generate_chapter_images.run("chapter-1")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(result, {"status": "locked"})
|
|
|
|
|
|
provider_cls.return_value.submit_generation.assert_not_called()
|
|
|
|
|
|
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
|
|
|
|
|
|
db.commit.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.SessionLocal")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.TencentCosStorageService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.LiblibImageProvider")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImagePromptService")
|
2026-03-11 13:18:20 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def test_generate_chapter_images_retries_when_any_item_generation_fails(
|
|
|
|
|
|
self,
|
2026-03-11 13:18:20 +08:00
|
|
|
|
_acquire_lock_mock,
|
|
|
|
|
|
_release_lock_mock,
|
2026-03-11 11:26:42 +08:00
|
|
|
|
prompt_service_cls,
|
|
|
|
|
|
provider_cls,
|
|
|
|
|
|
storage_cls,
|
|
|
|
|
|
session_local_cls,
|
|
|
|
|
|
):
|
2026-03-13 11:12:10 +08:00
|
|
|
|
chapter = _chapter_with_sections([
|
|
|
|
|
|
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
|
|
|
|
|
])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
db = Mock()
|
2026-03-13 11:12:10 +08:00
|
|
|
|
_bind_db_execute_to_chapter(db, chapter)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
session_local_cls.return_value = db
|
|
|
|
|
|
prompt_service_cls.return_value.build_prompt.return_value = {
|
|
|
|
|
|
"prompt": "A serene southern China town",
|
|
|
|
|
|
"style": "watercolor",
|
|
|
|
|
|
"size": "1024x1024",
|
|
|
|
|
|
"prompt_context": "childhood: 童年的夏天",
|
|
|
|
|
|
}
|
|
|
|
|
|
provider_cls.return_value.submit_generation.side_effect = RuntimeError("transient provider error")
|
|
|
|
|
|
|
|
|
|
|
|
retry_error = RuntimeError("retry requested")
|
|
|
|
|
|
task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock(side_effect=retry_error))
|
|
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
|
|
|
|
generate_chapter_images.run.__func__(task_self, "chapter-1")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertIs(ctx.exception, retry_error)
|
2026-03-13 11:12:10 +08:00
|
|
|
|
self.assertEqual(chapter.sections[0].image_record.status, "failed")
|
|
|
|
|
|
self.assertEqual(chapter.sections[0].image_record.error, "transient provider error")
|
2026-03-11 11:26:42 +08:00
|
|
|
|
task_self.retry.assert_called_once()
|
|
|
|
|
|
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
|
|
|
|
|
|
|
2026-03-10 16:05:13 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks.SessionLocal")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.TencentCosStorageService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.LiblibImageProvider")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImagePromptService")
|
2026-03-11 13:18:20 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
2026-03-10 16:05:13 +08:00
|
|
|
|
def test_generate_chapter_images_marks_successful_item_completed(
|
|
|
|
|
|
self,
|
2026-03-11 13:18:20 +08:00
|
|
|
|
_acquire_lock_mock,
|
|
|
|
|
|
_release_lock_mock,
|
2026-03-10 16:05:13 +08:00
|
|
|
|
prompt_service_cls,
|
|
|
|
|
|
provider_cls,
|
|
|
|
|
|
storage_cls,
|
|
|
|
|
|
session_local_cls,
|
|
|
|
|
|
):
|
2026-03-13 11:12:10 +08:00
|
|
|
|
chapter = _chapter_with_sections([
|
|
|
|
|
|
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
|
|
|
|
|
])
|
2026-03-10 16:05:13 +08:00
|
|
|
|
db = Mock()
|
2026-03-13 11:12:10 +08:00
|
|
|
|
_bind_db_execute_to_chapter(db, chapter)
|
2026-03-10 16:05:13 +08:00
|
|
|
|
session_local_cls.return_value = db
|
|
|
|
|
|
prompt_service_cls.return_value.build_prompt.return_value = {
|
|
|
|
|
|
"prompt": "A serene southern China town",
|
|
|
|
|
|
"style": "watercolor",
|
|
|
|
|
|
"size": "1024x1024",
|
|
|
|
|
|
"prompt_context": "childhood: 童年的夏天",
|
|
|
|
|
|
}
|
|
|
|
|
|
provider_inst = provider_cls.return_value
|
|
|
|
|
|
provider_inst.submit_generation.return_value = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"image_url": "https://provider.example.com/1.png",
|
|
|
|
|
|
}
|
2026-03-11 13:18:20 +08:00
|
|
|
|
png_buffer = BytesIO()
|
|
|
|
|
|
Image.new("RGB", (1, 1), color="white").save(png_buffer, format="PNG")
|
|
|
|
|
|
provider_inst.download_image.return_value = png_buffer.getvalue()
|
2026-03-10 16:05:13 +08:00
|
|
|
|
storage_inst = storage_cls.from_env.return_value
|
|
|
|
|
|
storage_inst.upload_bytes.return_value = "https://cos.example.com/memoirs/u1/c1/0.png"
|
|
|
|
|
|
|
|
|
|
|
|
generate_chapter_images.run("chapter-1")
|
|
|
|
|
|
|
2026-03-13 11:12:10 +08:00
|
|
|
|
self.assertEqual(chapter.sections[0].image_record.status, "completed")
|
|
|
|
|
|
self.assertEqual(chapter.sections[0].image_record.storage_key, "memoirs/user-1/chapter-1/0-7e1f860790.png")
|
|
|
|
|
|
self.assertEqual(chapter.sections[0].image_record.url, "https://cos.example.com/memoirs/u1/c1/0.png")
|
|
|
|
|
|
self.assertEqual(chapter.sections[0].image_record.prompt, "A serene southern China town")
|
2026-03-11 11:26:42 +08:00
|
|
|
|
provider_inst.close.assert_called_once()
|
2026-03-10 16:05:13 +08:00
|
|
|
|
db.commit.assert_called()
|
|
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks.SessionLocal")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.TencentCosStorageService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.LiblibImageProvider")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImagePromptService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImageSettings.from_env")
|
|
|
|
|
|
def test_generate_chapter_images_returns_disabled_when_feature_flag_is_off(
|
|
|
|
|
|
self,
|
|
|
|
|
|
settings_from_env,
|
|
|
|
|
|
prompt_service_cls,
|
|
|
|
|
|
provider_cls,
|
|
|
|
|
|
storage_cls,
|
|
|
|
|
|
session_local_cls,
|
|
|
|
|
|
):
|
2026-03-13 11:12:10 +08:00
|
|
|
|
chapter = _chapter_with_sections([
|
|
|
|
|
|
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
|
|
|
|
|
])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
settings_from_env.return_value = SimpleNamespace(
|
|
|
|
|
|
enabled=False,
|
|
|
|
|
|
max_per_chapter=2,
|
|
|
|
|
|
provider="liblib",
|
|
|
|
|
|
default_style="watercolor",
|
|
|
|
|
|
default_size="1024x1024",
|
|
|
|
|
|
poll_interval_seconds=3,
|
|
|
|
|
|
max_attempts=20,
|
|
|
|
|
|
liblib_template_uuid="tpl-uuid",
|
|
|
|
|
|
)
|
|
|
|
|
|
db = Mock()
|
2026-03-13 11:12:10 +08:00
|
|
|
|
_bind_db_execute_to_chapter(db, chapter)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
session_local_cls.return_value = db
|
|
|
|
|
|
|
|
|
|
|
|
result = generate_chapter_images.run("chapter-1")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(result, {"status": "disabled"})
|
|
|
|
|
|
prompt_service_cls.assert_not_called()
|
|
|
|
|
|
provider_cls.assert_not_called()
|
|
|
|
|
|
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
|
2026-03-14 23:58:46 +08:00
|
|
|
|
db.commit.assert_not_called()
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.SessionLocal")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.TencentCosStorageService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.LiblibImageProvider")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImagePromptService")
|
2026-03-11 13:18:20 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def test_generate_chapter_images_converts_non_png_payload_before_upload(
|
|
|
|
|
|
self,
|
2026-03-11 13:18:20 +08:00
|
|
|
|
_acquire_lock_mock,
|
|
|
|
|
|
_release_lock_mock,
|
2026-03-11 11:26:42 +08:00
|
|
|
|
prompt_service_cls,
|
|
|
|
|
|
provider_cls,
|
|
|
|
|
|
storage_cls,
|
|
|
|
|
|
session_local_cls,
|
|
|
|
|
|
):
|
2026-03-13 11:12:10 +08:00
|
|
|
|
chapter = _chapter_with_sections([
|
|
|
|
|
|
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
|
|
|
|
|
])
|
2026-03-11 11:26:42 +08:00
|
|
|
|
image_buffer = BytesIO()
|
|
|
|
|
|
Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG")
|
|
|
|
|
|
jpeg_bytes = image_buffer.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
db = Mock()
|
2026-03-13 11:12:10 +08:00
|
|
|
|
_bind_db_execute_to_chapter(db, chapter)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
session_local_cls.return_value = db
|
|
|
|
|
|
prompt_service_cls.return_value.build_prompt.return_value = {
|
|
|
|
|
|
"prompt": "A serene southern China town",
|
|
|
|
|
|
"style": "watercolor",
|
|
|
|
|
|
"size": "1024x1024",
|
|
|
|
|
|
"prompt_context": "childhood: 童年的夏天",
|
|
|
|
|
|
}
|
|
|
|
|
|
provider_inst = provider_cls.return_value
|
|
|
|
|
|
provider_inst.submit_generation.return_value = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"image_url": "https://provider.example.com/1.jpg",
|
|
|
|
|
|
}
|
|
|
|
|
|
provider_inst.download_image.return_value = jpeg_bytes
|
|
|
|
|
|
storage_inst = storage_cls.from_env.return_value
|
|
|
|
|
|
storage_inst.upload_bytes.return_value = "https://cos.example.com/memoirs/u1/c1/0.png"
|
|
|
|
|
|
|
|
|
|
|
|
generate_chapter_images.run("chapter-1")
|
|
|
|
|
|
|
|
|
|
|
|
upload_args = storage_inst.upload_bytes.call_args.args
|
|
|
|
|
|
self.assertTrue(upload_args[0].startswith(b"\x89PNG\r\n\x1a\n"))
|
|
|
|
|
|
self.assertEqual(upload_args[2], "image/png")
|
|
|
|
|
|
|
2026-03-11 15:20:59 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks.SessionLocal")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.TencentCosStorageService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.LiblibImageProvider")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImagePromptService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
|
|
|
|
|
def test_generate_chapter_images_fails_without_retry_on_permanent_cos_error(
|
|
|
|
|
|
self,
|
|
|
|
|
|
_acquire_lock_mock,
|
|
|
|
|
|
_release_lock_mock,
|
|
|
|
|
|
prompt_service_cls,
|
|
|
|
|
|
provider_cls,
|
|
|
|
|
|
storage_cls,
|
|
|
|
|
|
session_local_cls,
|
|
|
|
|
|
):
|
2026-03-13 11:12:10 +08:00
|
|
|
|
chapter = _chapter_with_sections([
|
|
|
|
|
|
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
|
|
|
|
|
])
|
2026-03-11 15:20:59 +08:00
|
|
|
|
image_buffer = BytesIO()
|
|
|
|
|
|
Image.new("RGB", (1, 1), color="white").save(image_buffer, format="PNG")
|
|
|
|
|
|
png_bytes = image_buffer.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
db = Mock()
|
2026-03-13 11:12:10 +08:00
|
|
|
|
_bind_db_execute_to_chapter(db, chapter)
|
2026-03-11 15:20:59 +08:00
|
|
|
|
session_local_cls.return_value = db
|
|
|
|
|
|
prompt_service_cls.return_value.build_prompt.return_value = {
|
|
|
|
|
|
"prompt": "A serene southern China town",
|
|
|
|
|
|
"style": "watercolor",
|
|
|
|
|
|
"size": "1024x1024",
|
|
|
|
|
|
"prompt_context": "childhood: 童年的夏天",
|
|
|
|
|
|
}
|
|
|
|
|
|
provider_inst = provider_cls.return_value
|
|
|
|
|
|
provider_inst.submit_generation.return_value = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"image_url": "https://provider.example.com/1.png",
|
|
|
|
|
|
}
|
|
|
|
|
|
provider_inst.download_image.return_value = png_bytes
|
|
|
|
|
|
storage_inst = storage_cls.from_env.return_value
|
|
|
|
|
|
storage_inst.upload_bytes.side_effect = memoir_tasks.CosUploadError(
|
|
|
|
|
|
"AccessDenied", retryable=False, request_id="req-403"
|
|
|
|
|
|
)
|
|
|
|
|
|
task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock())
|
2026-03-13 16:23:51 +08:00
|
|
|
|
img_rec = chapter.sections[0].image_record
|
2026-03-11 15:20:59 +08:00
|
|
|
|
|
2026-03-13 16:23:51 +08:00
|
|
|
|
result = generate_chapter_images.run.__func__(task_self, "chapter-1")
|
2026-03-11 15:20:59 +08:00
|
|
|
|
|
2026-03-13 16:23:51 +08:00
|
|
|
|
self.assertEqual(result, {"status": "success"})
|
|
|
|
|
|
self.assertIsNone(chapter.sections[0].image_id)
|
|
|
|
|
|
db.delete.assert_called_with(img_rec)
|
2026-03-11 15:20:59 +08:00
|
|
|
|
task_self.retry.assert_not_called()
|
|
|
|
|
|
|
2026-03-10 16:05:13 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks.SessionLocal")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.TencentCosStorageService")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.LiblibImageProvider")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks.MemoirImagePromptService")
|
2026-03-11 13:18:20 +08:00
|
|
|
|
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
|
|
|
|
|
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
2026-03-10 16:05:13 +08:00
|
|
|
|
def test_generate_chapter_images_skips_completed_items_for_idempotency(
|
|
|
|
|
|
self,
|
2026-03-11 13:18:20 +08:00
|
|
|
|
_acquire_lock_mock,
|
|
|
|
|
|
_release_lock_mock,
|
2026-03-10 16:05:13 +08:00
|
|
|
|
prompt_service_cls,
|
|
|
|
|
|
provider_cls,
|
|
|
|
|
|
storage_cls,
|
|
|
|
|
|
session_local_cls,
|
|
|
|
|
|
):
|
2026-03-13 11:12:10 +08:00
|
|
|
|
chapter = _chapter_with_sections([
|
|
|
|
|
|
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "completed", "url": "https://cos.example.com/already-there.png"}},
|
|
|
|
|
|
])
|
2026-03-10 16:05:13 +08:00
|
|
|
|
db = Mock()
|
2026-03-13 11:12:10 +08:00
|
|
|
|
_bind_db_execute_to_chapter(db, chapter)
|
2026-03-10 16:05:13 +08:00
|
|
|
|
session_local_cls.return_value = db
|
|
|
|
|
|
|
|
|
|
|
|
generate_chapter_images.run("chapter-1")
|
|
|
|
|
|
|
|
|
|
|
|
provider_cls.return_value.submit_generation.assert_not_called()
|
|
|
|
|
|
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
|