bba8beffef
CI 单测 mock job 缺少 cover_title_config 属性导致 pydantic ValidationError
452 lines
18 KiB
Python
452 lines
18 KiB
Python
from datetime import UTC, datetime, timezone
|
||
|
||
"""AI数字人渲染 API 路由测试 — #1798.
|
||
|
||
至少 10 个测试覆盖路由层逻辑。
|
||
"""
|
||
|
||
import os
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
import packages.middleware.points_gate as _pg_module
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _disable_points_gate(monkeypatch):
|
||
"""默认关闭积分闸门,避免影响既有用例。"""
|
||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: False)
|
||
yield
|
||
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||
|
||
|
||
def _make_mock_user(user_id="user-1"):
|
||
"""创建 mock 认证用户."""
|
||
user = MagicMock()
|
||
user.id = user_id
|
||
return user
|
||
|
||
|
||
def _make_mock_render_job(
|
||
job_id="render-1",
|
||
user_id="user-1",
|
||
status="pending",
|
||
progress=0,
|
||
output_video_url="",
|
||
output_cover_url="",
|
||
output_duration=0.0,
|
||
error_message="",
|
||
):
|
||
"""创建 mock 渲染任务."""
|
||
m = MagicMock()
|
||
m.id = job_id
|
||
m.user_id = user_id
|
||
m.project_id = ""
|
||
m.lipsync_job_id = "lipsync-1"
|
||
m.script_id = "script-1"
|
||
m.b_roll_segments = []
|
||
m.title_config = {}
|
||
m.cover_config = {}
|
||
m.cover_title_config = {}
|
||
m.status = status
|
||
m.progress = progress
|
||
m.output_video_url = output_video_url
|
||
m.output_cover_url = output_cover_url
|
||
m.output_duration = output_duration
|
||
m.error_message = error_message
|
||
m.submitted_at = None
|
||
m.started_at = None
|
||
m.completed_at = None
|
||
m.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||
m.updated_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||
return m
|
||
|
||
|
||
class TestRenderRoutes:
|
||
"""路由层测试(通过 mock service 测试路由逻辑)."""
|
||
|
||
def _get_client(self):
|
||
"""获取测试客户端."""
|
||
from app.main import app
|
||
from fastapi.testclient import TestClient
|
||
|
||
return TestClient(app)
|
||
|
||
def test_create_render_job_success(self):
|
||
from app.api.routes.ai_avatar_render import router
|
||
from app.schemas.ai_avatar_render import AiAvatarRenderJobResponse
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_job = _make_mock_render_job()
|
||
mock_service.create_render_job.return_value = mock_job
|
||
|
||
# 直接测试路由函数
|
||
from app.api.routes.ai_avatar_render import create_render_job
|
||
|
||
mock_user = _make_mock_user()
|
||
body = MagicMock()
|
||
body.lipsync_job_id = "lipsync-1"
|
||
body.script_id = "script-1"
|
||
body.b_roll_segments = []
|
||
body.title_config = {}
|
||
body.cover_config = {}
|
||
body.cover_title_config = {}
|
||
body.project_id = ""
|
||
|
||
result = create_render_job(
|
||
body=body,
|
||
current_user=mock_user,
|
||
svc=mock_service,
|
||
)
|
||
assert result.id == "render-1"
|
||
mock_service.create_render_job.assert_called_once()
|
||
|
||
def test_create_render_job_lipsync_not_found(self):
|
||
from app.api.routes.ai_avatar_render import create_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_service.create_render_job.side_effect = AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
|
||
|
||
mock_user = _make_mock_user()
|
||
body = MagicMock()
|
||
body.lipsync_job_id = "nonexistent"
|
||
body.script_id = "script-1"
|
||
body.b_roll_segments = []
|
||
body.title_config = {}
|
||
body.cover_config = {}
|
||
body.cover_title_config = {}
|
||
body.project_id = ""
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_render_job(body=body, current_user=mock_user, svc=mock_service)
|
||
assert exc_info.value.status_code == 404
|
||
|
||
def test_create_render_job_lipsync_not_completed(self):
|
||
from app.api.routes.ai_avatar_render import create_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_service.create_render_job.side_effect = AiAvatarRenderError(
|
||
"对口型任务状态为 processing", code="LipsyncJobNotCompleted"
|
||
)
|
||
|
||
mock_user = _make_mock_user()
|
||
body = MagicMock()
|
||
body.lipsync_job_id = "lipsync-1"
|
||
body.script_id = "script-1"
|
||
body.b_roll_segments = []
|
||
body.title_config = {}
|
||
body.cover_config = {}
|
||
body.cover_title_config = {}
|
||
body.project_id = ""
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
create_render_job(body=body, current_user=mock_user, svc=mock_service)
|
||
assert exc_info.value.status_code == 400
|
||
|
||
def test_get_render_job_success(self):
|
||
from app.api.routes.ai_avatar_render import get_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_job = _make_mock_render_job()
|
||
mock_service.get_render_job.return_value = mock_job
|
||
|
||
result = get_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||
assert result.id == "render-1"
|
||
|
||
def test_get_render_job_not_found(self):
|
||
from app.api.routes.ai_avatar_render import get_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_service.get_render_job.return_value = None
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
get_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||
assert exc_info.value.status_code == 404
|
||
|
||
def test_list_render_jobs(self):
|
||
from app.api.routes.ai_avatar_render import list_render_jobs
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
|
||
mock_service.list_render_jobs.return_value = (mock_jobs, 3)
|
||
|
||
result = list_render_jobs(
|
||
project_id="",
|
||
status="",
|
||
offset=0,
|
||
limit=20,
|
||
current_user=_make_mock_user(),
|
||
svc=mock_service,
|
||
)
|
||
assert result["total"] == 3
|
||
assert len(result["items"]) == 3
|
||
|
||
def test_cancel_render_job_success(self):
|
||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_job = _make_mock_render_job(status="cancelled")
|
||
mock_service.cancel_render_job.return_value = mock_job
|
||
|
||
result = cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||
assert result.status == "cancelled"
|
||
|
||
def test_cancel_render_job_not_found(self):
|
||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_service.cancel_render_job.return_value = None
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
cancel_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||
assert exc_info.value.status_code == 404
|
||
|
||
def test_cancel_render_job_not_cancellable(self):
|
||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_job = _make_mock_render_job(status="completed")
|
||
mock_service.cancel_render_job.return_value = mock_job
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||
assert exc_info.value.status_code == 400
|
||
|
||
def test_retry_render_job_success(self):
|
||
from app.api.routes.ai_avatar_render import retry_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_job = _make_mock_render_job(status="pending")
|
||
mock_service.retry_render_job.return_value = mock_job
|
||
|
||
result = retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||
assert result.status == "pending"
|
||
|
||
def test_retry_render_job_not_failed(self):
|
||
from app.api.routes.ai_avatar_render import retry_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_job = _make_mock_render_job(status="completed")
|
||
mock_service.retry_render_job.return_value = mock_job
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||
assert exc_info.value.status_code == 400
|
||
|
||
def test_retry_render_job_not_found(self):
|
||
from app.api.routes.ai_avatar_render import retry_render_job
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||
mock_service.retry_render_job.return_value = None
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
retry_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||
assert exc_info.value.status_code == 404
|
||
|
||
|
||
class TestBrollOverlayFilter:
|
||
"""FFmpeg B-roll 滤镜构建测试."""
|
||
|
||
def test_empty_segments_returns_empty(self):
|
||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||
|
||
result, label = build_broll_overlay_filter([], 30.0)
|
||
assert result == ""
|
||
assert label is None
|
||
|
||
def test_pip_mode_generates_overlay(self):
|
||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||
|
||
segments = [
|
||
{
|
||
"script_segment_index": 0,
|
||
"asset_url": "https://example.com/broll.mp4",
|
||
"mode": "pip",
|
||
"start_time": 5.0,
|
||
"end_time": 10.0,
|
||
"pip_position": "bottom_right",
|
||
"pip_scale": 0.3,
|
||
}
|
||
]
|
||
result, label = build_broll_overlay_filter(segments, 30.0)
|
||
assert "overlay" in result or "scale=" in result
|
||
assert label == "vout"
|
||
|
||
def test_fullscreen_mode_generates_concat(self):
|
||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||
|
||
segments = [
|
||
{
|
||
"script_segment_index": 0,
|
||
"asset_url": "https://example.com/broll.mp4",
|
||
"mode": "fullscreen",
|
||
"start_time": 5.0,
|
||
"end_time": 10.0,
|
||
}
|
||
]
|
||
result, label = build_broll_overlay_filter(segments, 30.0)
|
||
assert "trim" in result or "concat" in result
|
||
assert label == "vout_fs"
|
||
|
||
def test_cover_extract_command(self):
|
||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||
|
||
cmd = build_cover_extract_command({"timestamp": 5.0}, "/tmp/cover.jpg")
|
||
assert "ffmpeg" in cmd
|
||
assert "5.0" in cmd
|
||
assert "/tmp/cover.jpg" in cmd
|
||
|
||
def test_cover_extract_empty_config(self):
|
||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||
|
||
cmd = build_cover_extract_command({}, "/tmp/cover.jpg")
|
||
assert "ffmpeg" in cmd
|
||
|
||
def test_cover_extract_with_size(self):
|
||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||
|
||
cmd = build_cover_extract_command(
|
||
{"timestamp": 3.0, "width": 1280, "height": 720},
|
||
"/tmp/cover.jpg",
|
||
)
|
||
assert "scale=" in cmd
|
||
|
||
|
||
def _make_mock_auth_user(user_id="user-1"):
|
||
"""构造 AuthenticatedUser:current_user.user.id."""
|
||
auth = MagicMock()
|
||
auth.user.id = user_id
|
||
return auth
|
||
|
||
|
||
class TestRenderSmartCoverRoute:
|
||
"""POST /renders/{job_id}/smart-cover — 从成片智能抽封面(步骤②)."""
|
||
|
||
def test_smart_cover_job_not_found_returns_404(self):
|
||
"""渲染任务不存在 → 404."""
|
||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock()
|
||
mock_service.get_render_job.return_value = None
|
||
mock_db = MagicMock()
|
||
mock_user = _make_mock_auth_user()
|
||
|
||
# 函数内部 `from app.services.ai_avatar_render_service import AiAvatarRenderService`
|
||
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
generate_render_smart_cover(job_id="render-missing", current_user=mock_user, db=mock_db)
|
||
|
||
assert exc_info.value.status_code == 404
|
||
assert "不存在" in exc_info.value.detail
|
||
mock_service.get_render_job.assert_called_once_with("render-missing", "user-1")
|
||
|
||
def test_smart_cover_job_not_completed_returns_400(self):
|
||
"""任务未 completed(如 processing)→ 400."""
|
||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock()
|
||
mock_job = _make_mock_render_job(status="processing", output_video_url="https://oss/video.mp4")
|
||
mock_service.get_render_job.return_value = mock_job
|
||
mock_db = MagicMock()
|
||
mock_user = _make_mock_auth_user()
|
||
|
||
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||
|
||
assert exc_info.value.status_code == 400
|
||
assert "先完成视频生成" in exc_info.value.detail
|
||
|
||
def test_smart_cover_empty_video_url_returns_400(self):
|
||
"""已 completed 但 output_video_url 为空/空白 → 400."""
|
||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||
from fastapi import HTTPException
|
||
|
||
mock_service = MagicMock()
|
||
mock_job = _make_mock_render_job(status="completed", output_video_url=" ")
|
||
mock_service.get_render_job.return_value = mock_job
|
||
mock_db = MagicMock()
|
||
mock_user = _make_mock_auth_user()
|
||
|
||
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||
|
||
assert exc_info.value.status_code == 400
|
||
assert "URL 为空" in exc_info.value.detail
|
||
|
||
def test_smart_cover_success_updates_db_and_returns_url(self):
|
||
"""抽帧成功 → 更新 job.cover_config / output_cover_url 并 commit,返回 completed."""
|
||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||
|
||
mock_service = MagicMock()
|
||
mock_job = _make_mock_render_job(
|
||
status="completed",
|
||
output_video_url="https://oss/final.mp4",
|
||
)
|
||
mock_job.cover_config = {"mode": "manual"}
|
||
mock_service.get_render_job.return_value = mock_job
|
||
mock_db = MagicMock()
|
||
mock_user = _make_mock_auth_user()
|
||
|
||
with (
|
||
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
|
||
patch(
|
||
"app.api.routes.ai_avatar_render.generate_smart_cover", return_value="https://oss/cover.jpg"
|
||
) as mock_gen,
|
||
):
|
||
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||
|
||
mock_gen.assert_called_once_with("https://oss/final.mp4", job_id="render-1", max_frames=5)
|
||
assert result.status == "completed"
|
||
assert result.cover_url == "https://oss/cover.jpg"
|
||
assert mock_job.output_cover_url == "https://oss/cover.jpg"
|
||
assert mock_job.cover_config["mode"] == "auto_frame"
|
||
assert mock_job.cover_config["url"] == "https://oss/cover.jpg"
|
||
mock_db.commit.assert_called_once()
|
||
|
||
def test_smart_cover_extract_failure_returns_fallback_failed(self):
|
||
"""generate_smart_cover 抛异常 → fallback_failed,不抛错不写 DB."""
|
||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||
|
||
mock_service = MagicMock()
|
||
mock_job = _make_mock_render_job(status="completed", output_video_url="https://oss/final.mp4")
|
||
mock_service.get_render_job.return_value = mock_job
|
||
mock_db = MagicMock()
|
||
mock_user = _make_mock_auth_user()
|
||
|
||
with (
|
||
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
|
||
patch("app.api.routes.ai_avatar_render.generate_smart_cover", side_effect=RuntimeError("mediakit down")),
|
||
):
|
||
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||
|
||
assert result.status == "fallback_failed"
|
||
assert result.cover_url == ""
|
||
# 失败时不写 cover_config / 不 commit
|
||
mock_db.commit.assert_not_called()
|