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()