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
1711 lines
69 KiB
Python
1711 lines
69 KiB
Python
"""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):
|
||
"""步骤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("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):
|
||
"""步骤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("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):
|
||
"""步骤3:source_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
|