"""Tests for generation cover route — schema validation and import checks.""" from __future__ import annotations import pytest from pydantic import ValidationError def test_generation_cover_router_importable(): """新路由模块可以正确导入""" from app.api.routes.generation_cover import router assert router is not None # tags 应该是 Generation assert "Generation" in router.tags def test_generation_cover_route_path(): """路由路径应为 /generate-cover""" from app.api.routes.generation_cover import router paths = [route.path for route in router.routes] assert "/generate-cover" in paths def test_generation_cover_schemas_importable(): """Schema 可以从新模块导入""" from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse # 验证请求 schema 默认值 req = GenerateCoverRequest() assert req.asset_ids == [] assert req.cover_type == "ai_frame" assert req.frame_time is None # 验证响应 schema resp = GenerateCoverResponse(plan_id="p1", cover={"image_url": "http://x"}) assert resp.plan_id == "p1" assert resp.cover["image_url"] == "http://x" def test_generation_cover_schemas_not_in_templates_editor(): """旧的 templates_editor/schemas.py 不再包含封面 schema""" from app.api.routes.templates_editor import schemas as te_schemas assert not hasattr(te_schemas, "GenerateCoverRequest") assert not hasattr(te_schemas, "GenerateCoverResponse") def test_templates_editor_no_cover_router(): """templates_editor 不再包含 cover_router""" from app.api.routes.templates_editor import _sub_routers # cover_router 应该已被移除 for sub in _sub_routers: for route in sub.routes: assert "generate-cover" not in getattr(route, "path", ""), "templates_editor 不应再有 generate-cover 路由" def test_api_router_has_generation_cover(): """api_router 应该包含 /api/v1/generation/generate-cover 路径""" from app.api.router import api_router all_paths = [] for route in api_router.routes: if hasattr(route, "path"): all_paths.append(route.path) # 嵌套 router if hasattr(route, "routes"): for sub_route in route.routes: if hasattr(sub_route, "path"): all_paths.append(sub_route.path) # 应该能找到 generate-cover 路径 cover_paths = [p for p in all_paths if "generate-cover" in p] assert len(cover_paths) > 0, f"未找到 generate-cover 路由, 所有路径: {all_paths[:20]}" def test_generation_cover_request_validation(): """验证请求 schema 的字段约束""" from app.api.routes.generation_cover import GenerateCoverRequest # frame_time 不允许负数 with pytest.raises(ValidationError): GenerateCoverRequest(frame_time=-1.0) # 合法的 frame_time req = GenerateCoverRequest(frame_time=5.5) assert req.frame_time == 5.5 # 自定义 cover_type req2 = GenerateCoverRequest(cover_type="upload", asset_ids=["a1", "a2"]) assert req2.cover_type == "upload" assert req2.asset_ids == ["a1", "a2"] class TestUnifiedCoverPipelineEndpoint: """测试统一封面管道在 generate_cover endpoint 中的逻辑 (lines 189-215).""" def test_cover_url_from_generation_task(self): """当 GenerationTask 有 cover_url 时,直接返回该 URL 作为封面。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse # Mock plan with rendered_storage_key (so we skip the 3-step lookup) mock_plan = MagicMock() mock_plan.config = { "generation_task_id": "task-123", "rendered_storage_key": "rendered/plan-1/video.mp4", } # Mock plan_svc mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan # Mock template_svc mock_template_svc = MagicMock() # Mock generation task with cover_url mock_task = MagicMock() mock_task.cover_url = "https://oss.example.com/rendered/plan-1/cover.jpg" mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = mock_task mock_repo_cls.return_value = mock_repo # normalize_plan_config should return the config with cover mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/plan-1/cover.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-1", plan_id="plan-1", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) # 验证返回的封面数据来自 GenerationTask.cover_url assert result.plan_id == "plan-1" assert result.cover["image_url"] == "https://oss.example.com/rendered/plan-1/cover.jpg" assert result.cover["type"] == "ai_frame" def test_cover_url_all_fallbacks_fail_returns_400(self): """当所有步骤都找不到 cover_url 时,返回 400 而非 500。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest from fastapi import HTTPException mock_plan = MagicMock() mock_plan.config = { "generation_task_id": "task-456", "rendered_storage_key": "rendered/plan-2/video.mp4", } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() # Task has no cover_url mock_task = MagicMock() mock_task.cover_url = "" mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter, patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter, ): mock_repo = MagicMock() mock_repo.get.return_value = mock_task # No tasks found by source_edit_plan_id or user+template mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_storage_svc = MagicMock() mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/plan-2/video.mp4" mock_storage_getter.return_value = mock_storage_svc # MediaKit 抽帧也返回 None,模拟最终失败 mock_mk = MagicMock() mock_mk.is_available = True mock_mk.extract_frames.return_value = None mock_mk_getter.return_value = mock_mk # body 不传 asset_ids,步骤 E2 不会进入 from app.api.routes.generation_cover import generate_cover with pytest.raises(HTTPException) as exc_info: generate_cover( body=body, template_id="template-2", plan_id="plan-2", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) assert exc_info.value.status_code == 400 def test_cover_url_found_via_source_edit_plan(self): """步骤B:通过 source_edit_plan_id 找到预览任务的 cover_url。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() # No generation_task_id, so step A is skipped mock_plan.config = { "rendered_storage_key": "rendered/plan-x/video.mp4", } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() # Preview task found by source_edit_plan_id with cover_url mock_preview_task = MagicMock() mock_preview_task.id = "preview-task-abc" mock_preview_task.status = "completed" mock_preview_task.cover_url = "https://oss.example.com/rendered/preview/cover.jpg" mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/preview/cover.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-x", plan_id="plan-x", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) assert result.cover["image_url"] == "https://oss.example.com/rendered/preview/cover.jpg" mock_repo.list_by_source_edit_plan.assert_called_once_with("plan-x") def test_cover_url_found_via_user_template(self): """步骤C:通过 user+template 找到预览任务的 cover_url。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = { "rendered_storage_key": "rendered/plan-y/video.mp4", } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() # Step B finds nothing, step C finds a task mock_preview_task = MagicMock() mock_preview_task.id = "preview-task-def" mock_preview_task.status = "completed" mock_preview_task.cover_url = "https://oss.example.com/rendered/user-template-cover.jpg" mock_current_user = MagicMock() mock_current_user.user.id = "user-123" mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [mock_preview_task] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/user-template-cover.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-y", plan_id="plan-y", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=mock_current_user, ) assert result.cover["image_url"] == "https://oss.example.com/rendered/user-template-cover.jpg" mock_repo.list_latest_completed_preview.assert_called_once_with( user_id="user-123", template_id="template-y", ) def test_cover_url_found_via_cover_candidates_image_url(self): """步骤D:plan.config.cover_candidates 有 image_url 时,直接使用第一个候选封面。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() # 步骤A/B/C 都找不到,进入步骤D mock_plan.config = { "rendered_storage_key": "rendered/plan-z/video.mp4", # 必须有预览视频才能通过前置检查 "cover_candidates": [ {"image_url": "https://oss.example.com/candidates/cover-1.jpg", "score": 0.95}, {"image_url": "https://oss.example.com/candidates/cover-2.jpg", "score": 0.80}, ], } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/candidates/cover-1.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-z", plan_id="plan-z", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) # 步骤D从 cover_candidates 第一个元素的 image_url 提取封面 assert result.cover["image_url"] == "https://oss.example.com/candidates/cover-1.jpg" # 验证 plan.config 被更新(至少调用一次:rendered_storage_key + cover) assert mock_plan_svc.update_plan_config.call_count >= 1 def test_cover_url_found_via_cover_candidates_url_key(self): """步骤D:cover_candidates 用 url 键(非 image_url)时,也能正确提取。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = { "rendered_storage_key": "rendered/plan-w/video.mp4", # 必须有预览视频才能通过前置检查 "cover_candidates": [ {"url": "https://oss.example.com/candidates/alt-cover.jpg"}, ], } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/candidates/alt-cover.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-w", plan_id="plan-w", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) # 步骤D fallback 到 url 键 assert result.cover["image_url"] == "https://oss.example.com/candidates/alt-cover.jpg" def test_cover_candidates_skips_non_dict_first_element(self): """步骤D:cover_candidates 第一个元素不是 dict 时,安全跳过不崩溃。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest from fastapi import HTTPException mock_plan = MagicMock() mock_plan.config = { "rendered_storage_key": "rendered/plan-skip/video.mp4", # 必须有预览视频才能通过前置检查 "cover_candidates": ["not-a-dict", 42, None], } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo # storage fallback 也找不到封面 mock_storage_svc = MagicMock() mock_storage_svc.get_url.return_value = "" mock_storage_getter.return_value = mock_storage_svc from app.api.routes.generation_cover import generate_cover # 所有步骤都失败,应返回 400 with pytest.raises(HTTPException) as exc_info: generate_cover( body=body, template_id="template-skip", plan_id="plan-skip", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) assert exc_info.value.status_code == 400 class TestSourceEditPlanFallback: """测试步骤 2.5:通过 source_edit_plan_id 查找预览视频兜底逻辑。""" def test_step25_finds_video_by_source_edit_plan_id(self): """当步骤1和步骤2都找不到时,步骤2.5通过source_edit_plan_id找到预览视频和封面。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest # plan.config 没有 rendered_storage_key 和 generation_task_id mock_plan = MagicMock() mock_plan.config = {} mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() # Mock preview task found by source_edit_plan_id — with cover_url mock_preview_task = MagicMock() mock_preview_task.id = "preview-task-789" mock_preview_task.status = "completed" mock_preview_task.is_preview = True mock_preview_task.cover_url = "https://oss.example.com/rendered/cover.jpg" # Mock generated video mock_video = MagicMock() mock_video.file_url = "rendered/plan-x/video.mp4" mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo, patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = None # Both video lookup (step 2.5) and cover_url lookup (step B) use this mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task] mock_repo_cls.return_value = mock_repo mock_usecase = MagicMock() mock_usecase.execute.return_value = [mock_video] mock_usecase_cls.return_value = mock_usecase mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/cover.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-1", plan_id="plan-x", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) # Verify step 2.5 was called for video mock_repo.list_by_source_edit_plan.assert_called_with("plan-x") # Cover was found via unified pipeline step B assert result.cover["image_url"] == "https://oss.example.com/rendered/cover.jpg" def test_step25_skips_non_completed_or_non_preview_tasks(self): """步骤2.5跳过非completed或非is_preview的任务,继续到步骤3。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = {} mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() # Task that is not completed mock_task_failed = MagicMock() mock_task_failed.id = "task-failed" mock_task_failed.status = "failed" mock_task_failed.is_preview = True mock_task_failed.cover_url = "" # Task that is not preview mock_task_full = MagicMock() mock_task_full.id = "task-full" mock_task_full.status = "completed" mock_task_full.is_preview = False mock_task_full.cover_url = "" mock_db = MagicMock() # Step 3 fallback finds a valid preview task WITH cover_url mock_step3_task = MagicMock() mock_step3_task.id = "step3-task" mock_step3_task.status = "completed" mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg" mock_video = MagicMock() mock_video.file_url = "rendered/step3/video.mp4" body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo, patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [mock_task_failed, mock_task_full] # Video step 3 and cover step C both use list_latest_completed_preview mock_repo.list_latest_completed_preview.return_value = [mock_step3_task] mock_repo_cls.return_value = mock_repo mock_usecase = MagicMock() mock_usecase.execute.return_value = [mock_video] mock_usecase_cls.return_value = mock_usecase mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"} } mock_storage_svc = MagicMock() mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4" mock_storage_getter.return_value = mock_storage_svc from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-1", plan_id="plan-y", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) # Step 2.5 found tasks but none matched -> step 3 should be called mock_repo.list_by_source_edit_plan.assert_called() mock_repo.list_latest_completed_preview.assert_called() assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg" def test_step25_exception_does_not_block_step3(self): """步骤2.5异常时不影响步骤3兜底(视频和封面都通过步骤3找到)。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = {} mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_step3_task = MagicMock() mock_step3_task.id = "step3-task" mock_step3_task.status = "completed" mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg" mock_video = MagicMock() mock_video.file_url = "rendered/step3/video.mp4" mock_db = MagicMock() body = GenerateCoverRequest(cover_type="ai_frame") with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo, patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter, ): mock_repo = MagicMock() mock_repo.get.return_value = None # Both video step 2.5 and cover step B raise mock_repo.list_by_source_edit_plan.side_effect = RuntimeError("db error") # Step 3 / step C succeeds mock_repo.list_latest_completed_preview.return_value = [mock_step3_task] mock_repo_cls.return_value = mock_repo mock_usecase = MagicMock() mock_usecase.execute.return_value = [mock_video] mock_usecase_cls.return_value = mock_usecase mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"} } mock_storage_svc = MagicMock() mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4" mock_storage_getter.return_value = mock_storage_svc from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-1", plan_id="plan-z", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) # Step 3 was called after step 2.5 failed mock_repo.list_latest_completed_preview.assert_called() assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg" class TestStrayLoggerRemoved: """验证多余的 logger.info(plan_id, generation_task_id) 已被删除。""" def test_no_stray_logger_call_in_source(self): """源码中不应存在 logger.info(plan_id, generation_task_id) 这样的调用。""" import inspect from app.api.routes import generation_cover source = inspect.getsource(generation_cover) # The stray call was logger.info(\n plan_id,\n generation_task_id,\n) # with no format string — should not exist assert ( "logger.info(\n plan_id," not in source ), "Stray logger.info(plan_id, generation_task_id) should be removed" class TestUploadCoverType: """测试 cover_type=upload 封面上传功能。""" def test_upload_cover_saves_url_directly(self): """cover_type=upload 时直接保存 cover_url,不需要预览视频。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = {} mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_current_user = MagicMock() mock_current_user.user.id = "user-upload" body = GenerateCoverRequest( cover_type="upload", cover_url="https://oss.example.com/uploaded/cover.jpg", ) with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize: mock_normalize.return_value = { "cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="template-upload", plan_id="plan-upload", services=(mock_template_svc, mock_plan_svc), db=MagicMock(), current_user=mock_current_user, ) assert result.plan_id == "plan-upload" assert result.cover["type"] == "upload" assert result.cover["image_url"] == "https://oss.example.com/uploaded/cover.jpg" # 验证 plan config 被更新 mock_plan_svc.update_plan_config.assert_called_once() call_args = mock_plan_svc.update_plan_config.call_args assert call_args[0][0] == "plan-upload" assert call_args[0][1]["cover"]["type"] == "upload" def test_upload_cover_without_url_returns_400(self): """cover_type=upload 但未提供 cover_url 时返回 400。""" from unittest.mock import MagicMock from app.api.routes.generation_cover import GenerateCoverRequest from fastapi import HTTPException mock_plan = MagicMock() mock_plan.config = {} mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() body = GenerateCoverRequest(cover_type="upload") # cover_url is None by default import pytest from app.api.routes.generation_cover import generate_cover with pytest.raises(HTTPException) as exc_info: generate_cover( body=body, template_id="template-upload", plan_id="plan-upload", services=(mock_template_svc, mock_plan_svc), db=MagicMock(), current_user=MagicMock(), ) assert exc_info.value.status_code == 400 assert "cover_url" in exc_info.value.detail def test_upload_cover_schema_has_cover_url_field(self): """GenerateCoverRequest schema 包含 cover_url 字段。""" from app.api.routes.generation_cover import GenerateCoverRequest req = GenerateCoverRequest(cover_type="upload", cover_url="https://example.com/img.jpg") assert req.cover_url == "https://example.com/img.jpg" assert req.cover_type == "upload" # 默认值为 None req2 = GenerateCoverRequest() assert req2.cover_url is None def test_upload_cover_does_not_require_preview_video(self): """cover_type=upload 时不查找预览视频,即使 plan.config 为空也不报错。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = {} # 没有 rendered_storage_key mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() body = GenerateCoverRequest( cover_type="upload", cover_url="https://oss.example.com/uploaded/my-cover.png", ) with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize: mock_normalize.return_value = { "cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/my-cover.png"} } from app.api.routes.generation_cover import generate_cover # 不应该抛出 "请先生成预览视频" 的异常 result = generate_cover( body=body, template_id="template-1", plan_id="plan-no-preview", services=(mock_template_svc, mock_plan_svc), db=MagicMock(), current_user=MagicMock(), ) assert result.cover["image_url"] == "https://oss.example.com/uploaded/my-cover.png" # 验证没有调用任何预览视频查找逻辑 # (normalize_plan_config 是唯一被调用的外部函数) def test_cover_extracted_from_source_asset_when_no_preview(self): """步骤E2:无后端渲染产物时,直接从用户选择的视频素材抽帧。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = {} # 无 rendered_storage_key,无 generation_task_id mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() # 模拟视频素材 mock_asset = MagicMock() mock_asset.file_type = "video" mock_asset.storage_key = "uploads/source-clip.mp4" mock_asset_repo = MagicMock() mock_asset_repo.get.return_value = mock_asset mock_mk = MagicMock() mock_mk.is_available = True mock_mk.extract_frames.return_value = [{"image_url": "https://mediakit.internal/frame-abc.jpg"}] mock_storage = MagicMock() mock_storage.get_url.return_value = "https://oss.example.com/uploads/source-clip.mp4" body = GenerateCoverRequest( cover_type="ai_frame", asset_ids=["asset-video-1"], ) with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch( "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", return_value=mock_asset_repo, ), patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk), patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage), patch( "app.api.routes.generation_cover._persist_cover_frame", return_value="https://oss.example.com/covers/final.jpg", ), patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="tpl-source", plan_id="plan-source", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) assert result.cover["image_url"] == "https://oss.example.com/covers/final.jpg" mock_mk.extract_frames.assert_called_once() # 确保用的是源素材 URL call_kwargs = mock_mk.extract_frames.call_args.kwargs assert "source-clip.mp4" in call_kwargs["video_url"] def test_e2_passes_plan_title_to_persist_for_overlay(self): """步骤E2:plan.config.title.text 存在时,作为 title_text 传给 _persist_cover_frame 叠加标题。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = {"title": {"enabled": True, "text": "我的视频标题"}} mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() mock_asset = MagicMock() mock_asset.file_type = "video" mock_asset.storage_key = "uploads/src.mp4" mock_asset_repo = MagicMock() mock_asset_repo.get.return_value = mock_asset mock_mk = MagicMock() mock_mk.is_available = True mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}] mock_storage = MagicMock() mock_storage.get_url.return_value = "https://oss.example.com/uploads/src.mp4" body = GenerateCoverRequest(cover_type="ai_frame", asset_ids=["a1"]) with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch( "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", return_value=mock_asset_repo, ), patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk), patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage), patch( "app.api.routes.generation_cover._persist_cover_frame", return_value="https://oss.example.com/covers/final.jpg", ) as mock_persist, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="tpl", plan_id="plan-title", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) assert result.cover["image_url"] == "https://oss.example.com/covers/final.jpg" # 标题文字必须透传给持久化函数(用于源素材帧叠加标题) assert mock_persist.call_args.kwargs.get("title_text") == "我的视频标题" def test_e2_passes_full_title_style_to_persist(self): """步骤E2:plan.config.title 包含完整样式时,color/position/font_size 都传给 _persist_cover_frame。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = { "title": { "enabled": True, "text": "样式标题", "color": "#00ff00", "position": "top", "font_size": 42, } } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() mock_asset = MagicMock() mock_asset.file_type = "video" mock_asset.storage_key = "uploads/src.mp4" mock_asset_repo = MagicMock() mock_asset_repo.get.return_value = mock_asset mock_mk = MagicMock() mock_mk.is_available = True mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}] mock_storage = MagicMock() mock_storage.get_url.return_value = "https://oss.example.com/uploads/src.mp4" body = GenerateCoverRequest(cover_type="ai_frame", asset_ids=["a1"]) with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch( "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", return_value=mock_asset_repo, ), patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk), patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage), patch( "app.api.routes.generation_cover._persist_cover_frame", return_value="https://oss.example.com/covers/styled.jpg", ) as mock_persist, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/styled.jpg"} } from app.api.routes.generation_cover import generate_cover result = generate_cover( body=body, template_id="tpl", plan_id="plan-style", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) assert result.cover["image_url"] == "https://oss.example.com/covers/styled.jpg" kwargs = mock_persist.call_args.kwargs assert kwargs["title_text"] == "样式标题" assert kwargs["title_color"] == "#00ff00" assert kwargs["title_position"] == "top" assert kwargs["title_font_size"] == 42 def test_e2_title_style_fallback_font_color(self): """步骤E2:前端传 font_color 时能正确兼容读取。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest mock_plan = MagicMock() mock_plan.config = { "title": { "enabled": True, "text": "兼容标题", "font_color": "#123456", "position": "center", } } mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() mock_asset = MagicMock() mock_asset.file_type = "video" mock_asset.storage_key = "uploads/src.mp4" mock_asset_repo = MagicMock() mock_asset_repo.get.return_value = mock_asset mock_mk = MagicMock() mock_mk.is_available = True mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}] mock_storage = MagicMock() mock_storage.get_url.return_value = "https://oss.example.com/uploads/src.mp4" body = GenerateCoverRequest(cover_type="ai_frame", asset_ids=["a1"]) with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch( "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", return_value=mock_asset_repo, ), patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk), patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage), patch( "app.api.routes.generation_cover._persist_cover_frame", return_value="https://oss.example.com/covers/compat.jpg", ) as mock_persist, patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_normalize.return_value = { "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/compat.jpg"} } from app.api.routes.generation_cover import generate_cover generate_cover( body=body, template_id="tpl", plan_id="plan-compat", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) kwargs = mock_persist.call_args.kwargs assert kwargs["title_color"] == "#123456" assert kwargs["title_position"] == "center" assert kwargs["title_font_size"] is None def test_step_e_skips_non_video_assets(self): """步骤E2:asset_ids 里只有图片素材时,不调用 MediaKit 并返回 400。""" from unittest.mock import MagicMock, patch from app.api.routes.generation_cover import GenerateCoverRequest from fastapi import HTTPException mock_plan = MagicMock() mock_plan.config = {} mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan mock_template_svc = MagicMock() mock_db = MagicMock() mock_image_asset = MagicMock() mock_image_asset.file_type = "image" mock_image_asset.storage_key = "uploads/photo.png" mock_asset_repo = MagicMock() mock_asset_repo.get.return_value = mock_image_asset mock_mk = MagicMock() mock_mk.is_available = True body = GenerateCoverRequest( cover_type="ai_frame", asset_ids=["asset-img-1"], ) with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, patch( "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", return_value=mock_asset_repo, ), patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk), patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter, ): mock_repo = MagicMock() mock_repo.get.return_value = None mock_repo.list_by_source_edit_plan.return_value = [] mock_repo.list_latest_completed_preview.return_value = [] mock_repo_cls.return_value = mock_repo mock_storage_getter.return_value = MagicMock() from app.api.routes.generation_cover import generate_cover with pytest.raises(HTTPException) as exc_info: generate_cover( body=body, template_id="tpl-img", plan_id="plan-img", services=(mock_template_svc, mock_plan_svc), db=mock_db, current_user=MagicMock(), ) assert exc_info.value.status_code == 400 mock_mk.extract_frames.assert_not_called()