Merge branch 'refactor/backend-architecture' into development
This commit is contained in:
@@ -5,8 +5,25 @@ from unittest.mock import Mock, patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from api.tasks import memoir_tasks
|
||||
from api.tasks.memoir_tasks import generate_chapter_images
|
||||
from app.ports.image_gen import ImageResult, TaskStatus
|
||||
from app.tasks import memoir_tasks
|
||||
from app.tasks.memoir_tasks import generate_chapter_images
|
||||
|
||||
|
||||
def _mock_image_generator(*, image_url: str = "https://provider.example.com/1.png", image_bytes: bytes | None = None):
|
||||
"""构造满足 port ImageGenerator 的 mock:generate 返回 ImageResult,download_image 返回 bytes。"""
|
||||
if image_bytes is None:
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(buf, format="PNG")
|
||||
image_bytes = buf.getvalue()
|
||||
gen = Mock()
|
||||
gen.generate.return_value = ImageResult(
|
||||
status=TaskStatus.COMPLETED,
|
||||
task_id="",
|
||||
image_url=image_url,
|
||||
)
|
||||
gen.download_image.return_value = image_bytes
|
||||
return gen
|
||||
|
||||
|
||||
def _section_image_record(img_dict):
|
||||
@@ -71,17 +88,17 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
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")
|
||||
@patch("app.tasks.memoir_tasks.redis.from_url")
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@patch("app.tasks.memoir_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.memoir_tasks.get_image_generator")
|
||||
@patch("app.tasks.memoir_tasks.MemoirImagePromptService")
|
||||
def test_generate_chapter_images_skips_when_lock_is_already_held(
|
||||
self,
|
||||
prompt_service_cls,
|
||||
provider_cls,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
session_local_cls,
|
||||
get_sync_db_mock,
|
||||
redis_from_url,
|
||||
):
|
||||
chapter = _chapter_with_sections([
|
||||
@@ -89,44 +106,46 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
])
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
session_local_cls.return_value = db
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
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()
|
||||
get_image_generator_mock.return_value.generate.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")
|
||||
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@patch("app.tasks.memoir_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.memoir_tasks.get_image_generator")
|
||||
@patch("app.tasks.memoir_tasks.MemoirImagePromptService")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
def test_generate_chapter_images_retries_when_any_item_generation_fails(
|
||||
self,
|
||||
_acquire_lock_mock,
|
||||
_release_lock_mock,
|
||||
prompt_service_cls,
|
||||
provider_cls,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
session_local_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections([
|
||||
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
||||
])
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
session_local_cls.return_value = db
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
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")
|
||||
get_image_generator_mock.return_value.generate.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))
|
||||
@@ -140,41 +159,35 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
task_self.retry.assert_called_once()
|
||||
storage_cls.from_env.return_value.upload_bytes.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")
|
||||
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@patch("app.tasks.memoir_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.memoir_tasks.get_image_generator")
|
||||
@patch("app.tasks.memoir_tasks.MemoirImagePromptService")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
def test_generate_chapter_images_marks_successful_item_completed(
|
||||
self,
|
||||
_acquire_lock_mock,
|
||||
_release_lock_mock,
|
||||
prompt_service_cls,
|
||||
provider_cls,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
session_local_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections([
|
||||
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
||||
])
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
session_local_cls.return_value = db
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
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",
|
||||
}
|
||||
png_buffer = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(png_buffer, format="PNG")
|
||||
provider_inst.download_image.return_value = png_buffer.getvalue()
|
||||
get_image_generator_mock.return_value = _mock_image_generator()
|
||||
storage_inst = storage_cls.from_env.return_value
|
||||
storage_inst.upload_bytes.return_value = "https://cos.example.com/memoirs/u1/c1/0.png"
|
||||
|
||||
@@ -184,21 +197,21 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
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")
|
||||
provider_inst.close.assert_called_once()
|
||||
get_image_generator_mock.return_value.generate.assert_called_once()
|
||||
db.commit.assert_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")
|
||||
@patch("api.tasks.memoir_tasks.MemoirImageSettings.from_env")
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@patch("app.tasks.memoir_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.memoir_tasks.get_image_generator")
|
||||
@patch("app.tasks.memoir_tasks.MemoirImagePromptService")
|
||||
@patch("app.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,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
session_local_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections([
|
||||
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
||||
@@ -215,30 +228,31 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
)
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
session_local_cls.return_value = db
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
|
||||
result = generate_chapter_images.run("chapter-1")
|
||||
|
||||
self.assertEqual(result, {"status": "disabled"})
|
||||
prompt_service_cls.assert_not_called()
|
||||
provider_cls.assert_not_called()
|
||||
get_image_generator_mock.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")
|
||||
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@patch("app.tasks.memoir_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.memoir_tasks.get_image_generator")
|
||||
@patch("app.tasks.memoir_tasks.MemoirImagePromptService")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
def test_generate_chapter_images_converts_non_png_payload_before_upload(
|
||||
self,
|
||||
_acquire_lock_mock,
|
||||
_release_lock_mock,
|
||||
prompt_service_cls,
|
||||
provider_cls,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
session_local_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections([
|
||||
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
||||
@@ -249,19 +263,18 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
session_local_cls.return_value = db
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
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
|
||||
get_image_generator_mock.return_value = _mock_image_generator(
|
||||
image_url="https://provider.example.com/1.jpg",
|
||||
image_bytes=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"
|
||||
|
||||
@@ -271,43 +284,35 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
self.assertTrue(upload_args[0].startswith(b"\x89PNG\r\n\x1a\n"))
|
||||
self.assertEqual(upload_args[2], "image/png")
|
||||
|
||||
@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)
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@patch("app.tasks.memoir_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.memoir_tasks.get_image_generator")
|
||||
@patch("app.tasks.memoir_tasks.MemoirImagePromptService")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.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,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
session_local_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections([
|
||||
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
|
||||
])
|
||||
image_buffer = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_buffer, format="PNG")
|
||||
png_bytes = image_buffer.getvalue()
|
||||
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
session_local_cls.return_value = db
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
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
|
||||
get_image_generator_mock.return_value = _mock_image_generator()
|
||||
storage_inst = storage_cls.from_env.return_value
|
||||
storage_inst.upload_bytes.side_effect = memoir_tasks.CosUploadError(
|
||||
"AccessDenied", retryable=False, request_id="req-403"
|
||||
@@ -322,29 +327,30 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
db.delete.assert_called_with(img_rec)
|
||||
task_self.retry.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")
|
||||
@patch("api.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@patch("app.tasks.memoir_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.memoir_tasks.get_image_generator")
|
||||
@patch("app.tasks.memoir_tasks.MemoirImagePromptService")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
def test_generate_chapter_images_skips_completed_items_for_idempotency(
|
||||
self,
|
||||
_acquire_lock_mock,
|
||||
_release_lock_mock,
|
||||
prompt_service_cls,
|
||||
provider_cls,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
session_local_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections([
|
||||
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "completed", "url": "https://cos.example.com/already-there.png"}},
|
||||
])
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
session_local_cls.return_value = db
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
|
||||
generate_chapter_images.run("chapter-1")
|
||||
|
||||
provider_cls.return_value.submit_generation.assert_not_called()
|
||||
get_image_generator_mock.return_value.generate.assert_not_called()
|
||||
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user