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
788 lines
30 KiB
Python
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"
|