Files
xiaoxia-saas/tests/unit/test_ai_avatar_render_service.py
T
CI Bot 135e422d45
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 51s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m11s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 2m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m2s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m51s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m47s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 36s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 67h58m7s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 68h5m29s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 68h5m41s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 68h5m17s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 68h5m43s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 68h5m44s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 68h5m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 67h57m43s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 67h57m40s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 67h57m44s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 67h57m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 68h5m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 68h5m7s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 68h5m18s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 68h6m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 68h40m30s
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-13 11:16:39 +00:00

788 lines
30 KiB
Python

"""AI数字人渲染 Service 单元测试 — #1798.
至少 15 个测试覆盖 Service 层核心逻辑。
"""
import os
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
def _make_mock_db():
"""创建 mock 数据库 session."""
mock_db = MagicMock()
mock_db.add = MagicMock()
mock_db.flush = MagicMock()
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock()
return mock_db
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="",
lipsync_job_id="lipsync-1",
script_id="script-1",
):
"""创建 mock 渲染任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.project_id = ""
m.lipsync_job_id = lipsync_job_id
m.script_id = script_id
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 = None
m.updated_at = None
return m
def _make_mock_lipsync_job(
job_id="lipsync-1",
user_id="user-1",
status="completed",
output_video_url="https://output.mp4",
output_duration=30.0,
):
"""创建 mock 对口型任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.status = status
m.output_video_url = output_video_url
m.output_duration = output_duration
return m
def _make_mock_script(script_id="script-1", user_id="user-1"):
"""创建 mock 文案."""
m = MagicMock()
m.id = script_id
m.user_id = user_id
m.title = "测试文案"
return m
class TestSchemaValidation:
"""Schema 验证测试."""
def test_valid_broll_segment(self):
from app.schemas.ai_avatar_render import BRollSegment
seg = BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
assert seg.mode == "fullscreen"
assert seg.start_time == 5.0
def test_invalid_mode(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="fullscreen 或 pip"):
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="invalid",
start_time=5.0,
end_time=10.0,
)
def test_end_time_must_exceed_start_time(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="end_time 必须大于 start_time"):
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="fullscreen",
start_time=10.0,
end_time=5.0,
)
def test_asset_url_must_be_http(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="HTTP"):
BRollSegment(
script_segment_index=0,
asset_url="ftp://example.com/broll.mp4",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
def test_asset_url_empty(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="不能为空"):
BRollSegment(
script_segment_index=0,
asset_url=" ",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
def test_create_request_valid(self):
from app.schemas.ai_avatar_render import BRollSegment, CreateAiAvatarRenderRequest
req = CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="pip",
start_time=5.0,
end_time=10.0,
)
],
)
assert req.lipsync_job_id == "lipsync-1"
assert len(req.b_roll_segments) == 1
def test_create_request_empty_lipsync_job_id(self):
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="lipsync_job_id 不能为空"):
CreateAiAvatarRenderRequest(
lipsync_job_id=" ",
script_id="script-1",
)
def test_create_request_empty_script_id_normalized(self):
"""script_id 改为可选(TTS 直生场景):空白值应规范化为空串而非抛错。"""
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
req = CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id=" ",
)
assert req.script_id == ""
def test_create_request_empty_lipsync_job_id_raises(self):
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="lipsync_job_id 不能为空"):
CreateAiAvatarRenderRequest(
lipsync_job_id=" ",
script_id="script-1",
)
class TestAiAvatarRenderService:
"""Service 层单元测试(纯 mock,不依赖数据库)."""
def test_create_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
# 模拟 query 链式调用
mock_query = MagicMock()
# 第一次 query: LipsyncJobModel
mock_lipsync_filter = MagicMock()
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
mock_lipsync_query = MagicMock()
mock_lipsync_query.filter.return_value = mock_lipsync_filter
# 第二次 query: ScriptModel
mock_script_filter = MagicMock()
mock_script_filter.first.return_value = _make_mock_script()
mock_script_query = MagicMock()
mock_script_query.filter.return_value = mock_script_filter
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
svc = AiAvatarRenderService(mock_db)
job = svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
assert job.status == "pending"
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
def test_create_job_lipsync_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="对口型任务不存在"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="nonexistent",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_lipsync_not_completed(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(status="processing")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_lipsync_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="仅 completed 状态可渲染"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_lipsync_no_output(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_video_url="")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_lipsync_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="输出视频 URL 为空"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_script_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_query = MagicMock()
mock_lipsync_filter = MagicMock()
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
mock_lipsync_query.filter.return_value = mock_lipsync_filter
mock_script_query = MagicMock()
mock_script_filter = MagicMock()
mock_script_filter.first.return_value = None
mock_script_query.filter.return_value = mock_script_filter
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="文案不存在或无权访问"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="nonexistent",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_get_render_job_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.get_render_job("render-1", "user-1")
assert result is mock_job
def test_get_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.get_render_job("nonexistent", "user-1")
assert result is None
def test_list_render_jobs(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 3
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = mock_jobs
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
items, total = svc.list_render_jobs(user_id="user-1")
assert total == 3
assert len(items) == 3
def test_list_render_jobs_with_project_filter(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 1
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = [_make_mock_render_job()]
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
items, total = svc.list_render_jobs(user_id="user-1", project_id="proj-1")
assert total == 1
# filter should be called for user_id and project_id
assert mock_query.filter.call_count >= 2
def test_cancel_render_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="pending")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("render-1", "user-1")
assert result is mock_job
assert mock_job.status == "cancelled"
def test_cancel_render_job_not_pending(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="completed")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("render-1", "user-1")
# 非 pending 状态不可取消,状态不变
assert result.status == "completed"
def test_cancel_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("nonexistent", "user-1")
assert result is None
def test_retry_render_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="failed", error_message="渲染失败")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("render-1", "user-1")
assert result.status == "pending"
assert result.progress == 0
assert result.error_message == ""
def test_retry_render_job_not_failed(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="completed")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("render-1", "user-1")
assert result is None
def test_retry_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("nonexistent", "user-1")
assert result is None
def test_execute_render_job_not_found(self):
"""execute_render 在任务不存在时应静默返回."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
# 不应抛异常
svc.execute_render("nonexistent")
def test_execute_render_cancelled_job(self):
"""execute_render 在任务已取消时应静默返回."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="cancelled")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
svc.execute_render("render-1")
# 不应执行渲染逻辑
mock_db.commit.assert_not_called()
def test_execute_render_completed_does_not_auto_persist(self):
"""execute_render 完成后不自动入库成片库(改为用户点「完成」时由 finalize_job 入库)."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(
status="completed",
output_video_url="https://oss/lipsync.mp4",
output_duration=30.0,
)
mock_job = _make_mock_render_job(
job_id="render-ok",
status="pending",
output_video_url="",
output_cover_url="",
output_duration=0.0,
)
mock_filter = MagicMock()
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
patch("subprocess.run") as mock_run,
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
):
import subprocess as _sp
mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
svc.execute_render("render-ok")
# 状态应为 completed,但没有自动入库
assert mock_job.status == "completed"
gv_create.assert_not_called()
assert mock_job.output_video_url.startswith("https://oss/")
def test_finalize_job_persists_to_library(self):
"""finalize_job 在用户点「完成」后写入成片库,thumbnail_url 使用 job.output_cover_url."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = MagicMock()
mock_job = _make_mock_render_job(
job_id="render-finalize",
status="completed",
output_video_url="https://oss/ai-avatar/render-finalize/output.mp4",
output_cover_url="https://oss/cover.jpg",
output_duration=12.0,
)
# get_render_job → db.query(AiAvatarRenderJob).filter().first() 返回 mock_job
# finalize 幂等检查 → db.query(GeneratedVideoModel).filter().first() 返回 None(未入库)
def _query_side_effect(model):
q = MagicMock()
if model.__name__ == "AiAvatarRenderJob":
q.filter.return_value.first.return_value = mock_job
else:
# GeneratedVideoModel
q.filter.return_value.first.return_value = None
return q
mock_db.query.side_effect = _query_side_effect
svc = AiAvatarRenderService(mock_db)
with (
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
mock_clip = MagicMock()
mock_clip.id = "clip-new"
mock_clip.thumbnail_url = "https://oss/cover.jpg"
gv_create.return_value = mock_clip
mock_repo = MagicMock()
mock_repo.create.return_value = mock_clip
mock_repo.get.return_value = mock_clip
repo_cls.return_value = mock_repo
video = svc.finalize_job("render-finalize", "user-1")
assert video.id == "clip-new"
gv_create.assert_called_once()
call_kwargs = gv_create.call_args.kwargs
assert call_kwargs["file_url"].endswith("output.mp4")
assert call_kwargs["thumbnail_url"] == "https://oss/cover.jpg"
assert call_kwargs["generation_task_id"] == "render-finalize"
mock_repo.create.assert_called_once()
def test_finalize_job_idempotent_when_already_persisted(self):
"""finalize_job 重复调用:幂等检查命中后直接返回已有记录,不再 create."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = MagicMock()
mock_job = _make_mock_render_job(
job_id="render-finalize-2",
status="completed",
output_video_url="https://oss/output.mp4",
output_cover_url="https://oss/cover.jpg",
output_duration=12.0,
)
existing_model = MagicMock()
existing_model.id = "clip-existing"
existing_model.thumbnail_url = "https://oss/cover.jpg"
def _query_side_effect(model):
q = MagicMock()
if model.__name__ == "AiAvatarRenderJob":
q.filter.return_value.first.return_value = mock_job
else:
q.filter.return_value.first.return_value = existing_model
return q
mock_db.query.side_effect = _query_side_effect
svc = AiAvatarRenderService(mock_db)
with (
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
mock_existing = MagicMock()
mock_existing.id = "clip-existing"
mock_repo = MagicMock()
mock_repo.get.return_value = mock_existing
repo_cls.return_value = mock_repo
video = svc.finalize_job("render-finalize-2", "user-1")
assert video.id == "clip-existing"
gv_create.assert_not_called()
mock_repo.create.assert_not_called()
def test_finalize_job_requires_completed_status(self):
"""finalize_job 在非 completed 状态下抛异常."""
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = MagicMock()
mock_job = _make_mock_render_job(
job_id="render-pending",
status="processing",
output_video_url="",
output_cover_url="",
output_duration=0.0,
)
filter_mock = MagicMock()
filter_mock.first.return_value = mock_job
query_mock = MagicMock()
query_mock.filter.return_value = filter_mock
mock_db.query.return_value = query_mock
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError):
svc.finalize_job("render-pending", "user-1")
def test_execute_render_clip_failure_does_not_affect_render(self):
"""成片创建失败不影响渲染任务标记为成功."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(
status="completed",
output_video_url="https://oss/lipsync.mp4",
output_duration=30.0,
)
mock_job = _make_mock_render_job(
job_id="render-clip-fail",
status="pending",
output_video_url="",
output_cover_url="",
output_duration=0.0,
)
mock_filter = MagicMock()
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
patch("subprocess.run") as mock_run,
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
):
import subprocess as _sp
mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
svc.execute_render("render-clip-fail")
# 渲染任务仍应标记为 completed(不入库不影响渲染成功)
assert mock_job.status == "completed"
def test_error_exception_has_code(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError
err = AiAvatarRenderError("测试错误", code="TestCode")
assert err.code == "TestCode"
assert str(err) == "测试错误"
class TestAiAvatarRenderCoverPassthrough:
"""execute_render 中封面透传逻辑(320~329 行):cover_config 含 url/imageUrl/cover_url 时直接透传到 output_cover_url."""
def _run_execute(self, mock_job, mock_lipsync_job):
"""驱动 execute_render 跑到完成阶段的通用脚手架(mock IO 部分)."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
# query.filter 返回同一个 filter 两次(render_job 查询、lipsync 查询)
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
patch("subprocess.run") as mock_run,
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
patch("app.services.ai_avatar_cover_service.generate_smart_cover", return_value=""),
patch("packages.domain.generated_video.GeneratedVideo.create", return_value=MagicMock()),
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
import subprocess as _sp
mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
import tempfile as _tf
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
repo_cls.return_value = MagicMock()
svc.execute_render(mock_job.id)
return mock_db, mock_job
def test_cover_url_in_cover_config_passthrough_to_output_cover(self):
"""cover_config.url 存在 → 透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-1", status="pending")
mock_job.cover_config = {"mode": "upload", "url": "https://oss/user-cover.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover.jpg"
def test_cover_imageurl_fallback_also_passthrough(self):
"""cover_config.imageUrl(老字段)存在 → 也透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-2", status="pending")
mock_job.cover_config = {"mode": "upload", "imageUrl": "https://oss/user-cover2.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover2.jpg"