e250132ace
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Style (push) Successful in 2m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Validate - Security (push) Successful in 4m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m5s
CI/CD Pipeline / Unit Tests (push) Failing after 7m43s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 92h5m54s
CI/CD Pipeline / Build Production API Image (push) Failing after 92h5m55s
CI/CD Pipeline / PR Build Web Image (push) Failing after 92h13m43s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 92h13m22s
CI/CD Pipeline / PR Build API Image (push) Failing after 92h13m23s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 92h12m19s
CI/CD Pipeline / Deploy Production (push) Failing after 92h5m29s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 92h12m19s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 92h13m24s
CI/CD Pipeline / Canary Release to Production (push) Failing after 92h5m29s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 92h5m33s
CI/CD Pipeline / Build Production Web Image (push) Failing after 92h5m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 92h13m17s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 92h47m35s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com> Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
438 lines
17 KiB
Python
438 lines
17 KiB
Python
from datetime import datetime, timezone
|
||
|
||
"""AI数字人渲染 API 路由测试 — #1798.
|
||
|
||
至少 10 个测试覆盖路由层逻辑。
|
||
"""
|
||
|
||
import os
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
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.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=timezone.utc)
|
||
m.updated_at = datetime(2026, 1, 1, tzinfo=timezone.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.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.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.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()
|