Files
xiaoxia-saas/tests/unit/test_generation_cover.py
CI Bot f9d243a5af
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m51s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m52s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m21s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m4s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m24s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 32s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m41s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m15s
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-08-28 16:59:07 +00:00

1711 lines
69 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("app.api.routes.generation_cover.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):
"""步骤Dplan.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):
"""步骤Dcover_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):
"""步骤Dcover_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("app.api.routes.generation_cover.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("app.api.routes.generation_cover.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("app.api.routes.generation_cover.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):
"""步骤E2plan.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):
"""步骤E2plan.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):
"""步骤E2asset_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("app.api.routes.generation_cover.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()
class TestCoverFromFinalVideo:
"""测试封面从最终成片任务(is_preview=False)获取视频源。"""
def test_generated_video_fields_in_schema(self):
"""请求体支持 generated_video_id 和 video_url 字段。"""
from app.api.routes.generation_cover import GenerateCoverRequest
req = GenerateCoverRequest(
generated_video_id="gv-001",
video_url="https://example.com/final.mp4",
)
assert req.generated_video_id == "gv-001"
assert req.video_url == "https://example.com/final.mp4"
# 默认 None
req_default = GenerateCoverRequest()
assert req_default.generated_video_id is None
assert req_default.video_url is None
def test_cover_uses_final_video_when_generated_video_id_provided(self):
"""传 generated_video_id 时,从该最终成片视频抽帧。"""
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()
# Generated video
mock_gv = MagicMock()
mock_gv.file_url = "rendered/final/video.mp4"
mock_gv.generation_task_id = "task-final-001"
mock_gv.user_id = "user-1"
# 最终成片任务
mock_final_task = MagicMock()
mock_final_task.id = "task-final-001"
mock_final_task.created_by_user_id = "user-1"
mock_final_task.cover_url = ""
mock_gv_repo = MagicMock()
mock_gv_repo.get.return_value = mock_gv
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
generated_video_id="gv-final-001",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
return_value="https://oss.example.com/covers/final-cover.jpg",
) as mock_persist,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_final_task
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/final/video.mp4"
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final-cover.jpg"}
}
from app.api.routes.generation_cover import generate_cover
result = generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-final",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert result.cover["image_url"] == "https://oss.example.com/covers/final-cover.jpg"
call_kwargs = mock_mk.extract_frames.call_args.kwargs
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
assert call_kwargs["strategy"] == "SpecifiedFrames"
assert call_kwargs["max_frames"] == 1
assert call_kwargs["max_retries"] == 0
mock_persist.assert_called_once()
def test_cover_uses_video_url_directly(self):
"""传 video_url 时,直接从该 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_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
video_url="https://oss.example.com/rendered/final/video.mp4",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
return_value="https://oss.example.com/covers/c.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_storage_svc = MagicMock()
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
}
from app.api.routes.generation_cover import generate_cover
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-url",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
call_kwargs = mock_mk.extract_frames.call_args.kwargs
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
def test_cover_prefers_final_task_over_preview_in_source_plan(self):
"""步骤3source_edit_plan 关联任务中,优先使用 is_preview=False 的最终成片。"""
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_preview = MagicMock()
mock_preview.id = "task-preview"
mock_preview.status = "completed"
mock_preview.is_preview = True
mock_preview.cover_url = ""
mock_final = MagicMock()
mock_final.id = "task-final"
mock_final.status = "completed"
mock_final.is_preview = False
mock_final.cover_url = ""
mock_video_preview = MagicMock()
mock_video_preview.file_url = "rendered/preview/video.mp4"
mock_video_final = MagicMock()
mock_video_final.file_url = "rendered/final/video.mp4"
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(cover_type="ai_frame")
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_list_videos,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
return_value="https://oss.example.com/covers/c.jpg",
),
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
# list_by_source_edit_plan 返回 [preview, final],最终成片排在后面
mock_repo.list_by_source_edit_plan.return_value = [mock_preview, mock_final]
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
# 根据 task id 返回不同 video
def get_videos(task_id):
if task_id == "task-final":
return [mock_video_final]
return [mock_video_preview]
mock_use_case = MagicMock()
mock_use_case.execute.side_effect = get_videos
mock_list_videos.return_value = mock_use_case
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.side_effect = lambda key: f"https://oss.example.com/{key}"
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
}
from app.api.routes.generation_cover import generate_cover
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-priority",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
# 应该使用 final video URL 抽帧,而非 preview
call_kwargs = mock_mk.extract_frames.call_args.kwargs
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
assert "rendered/preview" not in call_kwargs["video_url"]
def test_cover_generated_video_permission_denied(self):
"""generated_video_id 关联任务属于其他用户时,返回 403。"""
from unittest.mock import MagicMock, patch
import pytest
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_gv = MagicMock()
mock_gv.file_url = "rendered/other/video.mp4"
mock_gv.generation_task_id = "task-other"
mock_gv.user_id = "" # 老数据无 user_id,走关联任务归属校验
mock_other_task = MagicMock()
mock_other_task.created_by_user_id = "other-user"
mock_gv_repo = MagicMock()
mock_gv_repo.get.return_value = mock_gv
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
generated_video_id="gv-other",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_other_task
mock_repo_cls.return_value = mock_repo
from app.api.routes.generation_cover import generate_cover
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-perm",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 403
def test_cover_video_url_ssrf_blocked(self):
"""video_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_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
# SSRF 攻击载荷:内网元数据地址
body = GenerateCoverRequest(
cover_type="ai_frame",
video_url="http://100.100.100.200/latest/meta-data/",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch("app.api.routes.generation_cover._persist_cover_frame") 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_storage_svc = MagicMock()
mock_storage_svc.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
mock_storage_svc.endpoint = "oss-cn-hangzhou.aliyuncs.com"
mock_storage_svc.get_url.side_effect = lambda k: f"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/{k}"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": "https://mk/f.jpg"}}
from app.api.routes.generation_cover import generate_cover
from fastapi import HTTPException
# 内网 URL 被白名单拦截后,无任何可用视频源 → 400(而不是向内网发请求)
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-ssrf",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 400
# MediaKit 从未被要求抽帧该内网地址
if mock_mk.extract_frames.called:
called_url = mock_mk.extract_frames.call_args.kwargs.get("video_url", "")
assert "100.100.100.200" not in called_url
assert "meta-data" not in called_url
def test_cover_generated_video_ownership_unverifiable_denied(self):
"""video 无 user_id 且关联任务不存在时,归属无法确认 → 403(防权限绕过)。"""
from unittest.mock import MagicMock, patch
import pytest
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_gv = MagicMock()
mock_gv.file_url = "rendered/mystery/video.mp4"
mock_gv.generation_task_id = "task-gone" # 关联任务已删除
mock_gv.user_id = "" # 老数据无 owner
mock_gv_repo = MagicMock()
mock_gv_repo.get.return_value = mock_gv
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
generated_video_id="gv-mystery",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
):
mock_repo = MagicMock()
mock_repo.get.return_value = None # 关联任务查不到
mock_repo_cls.return_value = mock_repo
from app.api.routes.generation_cover import generate_cover
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-orphan",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 403
def test_is_trusted_media_url_blocks_internal_and_ipv6(self):
"""白名单函数:内网 IPv4/IPv6/元数据地址一律拒绝,自家 OSS 域名放行。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import _is_trusted_media_url
mock_storage = MagicMock()
mock_storage.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
mock_storage.endpoint = "oss-cn-hangzhou.aliyuncs.com"
with patch(
"app.api.routes.generation_cover.get_shared_storage_service",
return_value=mock_storage,
):
# 内网 / 元数据 / IPv6 本地地址全部拒绝
for bad in [
"http://127.0.0.1/admin",
"http://10.0.0.5/video.mp4",
"http://192.168.1.1/video.mp4",
"http://172.16.0.1/video.mp4",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]:8080/video.mp4",
"http://[fe80::1]/video.mp4",
"http://[fc00::1]/video.mp4",
"http://localhost/x",
"ftp://oss-cn-hangzhou.aliyuncs.com/a.mp4",
"",
]:
assert _is_trusted_media_url(bad) is False, f"应拒绝: {bad}"
# 自家 OSS 域名(含签名 URL 子路径、bucket 域名)放行
for good in [
"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/final/v.mp4",
"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/v.mp4?Expires=123&Signature=abc",
]:
assert _is_trusted_media_url(good) is True, f"应放行: {good}"
def test_is_trusted_media_url_endpoint_with_scheme_parsed(self):
"""endpoint 配置带 http:// 前缀时也能正确提取主机名,不出现 .http 后缀绕过。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import _is_trusted_media_url
mock_storage = MagicMock()
mock_storage.public_url = "http://oss.internal.example.com:9000"
mock_storage.endpoint = "http://oss.internal.example.com:9000"
with patch(
"app.api.routes.generation_cover.get_shared_storage_service",
return_value=mock_storage,
):
# 正确域名放行
assert _is_trusted_media_url("http://oss.internal.example.com:9000/a/b.mp4") is True
# 伪造后缀域名必须拒绝(修复前 split(':')[0] 会取到 'http' 导致绕过)
assert _is_trusted_media_url("http://evil-http.com/x.mp4") is False
assert _is_trusted_media_url("http://evil.http/x.mp4") is False