168fcce36e
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m41s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m46s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m59s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 26s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 140h51m15s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 140h51m15s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 140h51m18s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 140h51m18s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 140h51m18s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 140h57m51s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 140h57m51s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 140h57m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 140h57m57s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 140h58m2s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 140h58m3s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 140h58m4s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 140h58m5s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 140h58m5s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 140h58m12s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 140h58m10s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 140h58m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 141h32m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 141h33m0s
625 lines
22 KiB
Python
625 lines
22 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_success_creates_clip_record(self):
|
|
"""execute_render 完成后自动创建成片记录到成片库."""
|
|
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("os.system", return_value=0),
|
|
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
|
|
patch(
|
|
"app.services.ai_avatar_cover_service.generate_smart_cover", return_value="https://oss/smart_cover.jpg"
|
|
),
|
|
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
|
) as repo_cls,
|
|
):
|
|
import tempfile as _tf
|
|
|
|
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
|
|
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
|
|
|
|
mock_clip = MagicMock()
|
|
mock_clip.id = "clip-001"
|
|
gv_create.return_value = mock_clip
|
|
mock_repo = MagicMock()
|
|
repo_cls.return_value = mock_repo
|
|
|
|
svc.execute_render("render-ok")
|
|
|
|
assert mock_job.status == "completed"
|
|
gv_create.assert_called_once()
|
|
call_kwargs = gv_create.call_args
|
|
assert "https://oss/" in call_kwargs.kwargs["file_url"]
|
|
assert call_kwargs.kwargs["user_id"] == "user-1"
|
|
mock_repo.create.assert_called_once_with(mock_clip)
|
|
|
|
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("os.system", return_value=0),
|
|
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
|
|
patch("app.services.ai_avatar_cover_service.generate_smart_cover", side_effect=RuntimeError("DB error")),
|
|
):
|
|
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) == "测试错误"
|