f73837c7b4
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Validate - Style (push) Successful in 2m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m40s
CI/CD Pipeline / Unit Tests (push) Successful in 9m28s
CI/CD Pipeline / Validate - Security (push) Successful in 22m56s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 139h17m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 139h17m41s
CI/CD Pipeline / Deploy Production (push) Failing after 139h17m40s
CI/CD Pipeline / Build Production Web Image (push) Failing after 139h17m41s
CI/CD Pipeline / Build Production API Image (push) Failing after 139h17m41s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 139h34m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 139h34m34s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 139h34m32s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 139h38m57s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 139h38m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 139h38m58s
CI/CD Pipeline / PR Build Web Image (push) Failing after 139h40m33s
CI/CD Pipeline / PR Build API Image (push) Failing after 139h40m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 139h40m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 139h40m40s
CI/CD Pipeline / CI Gate (push) Failing after 139h52m33s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 140h15m26s
624 lines
24 KiB
Python
624 lines
24 KiB
Python
"""AI 数字人口型视频生成速度优化 — 单元测试.
|
||
|
||
验证两个优化点:
|
||
1. FFmpeg 编码 preset 从 fast 改为 veryfast(提速 30~50%)
|
||
2. TTS 合成从同步改为 Celery 异步任务(API 响应从 6~35s 降到 <1s)
|
||
|
||
Issue: lipsync-speed-optimization
|
||
"""
|
||
|
||
import os
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 优化1: FFmpeg 编码提速 — preset veryfast
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestFFmpegPresetOptimization:
|
||
"""验证 FFmpeg 编码命令从 -preset fast 改为 -preset veryfast."""
|
||
|
||
def test_preset_is_veryfast(self):
|
||
"""_build_ffmpeg_command 输出必须包含 -preset veryfast."""
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
|
||
cmd = svc._build_ffmpeg_command(
|
||
input_video="https://example.com/video.mp4",
|
||
b_roll_segments=[],
|
||
filter_complex="",
|
||
final_label=None,
|
||
output_path="/tmp/output.mp4",
|
||
)
|
||
assert "-preset veryfast" in cmd, f"期望 -preset veryfast,实际命令: {cmd}"
|
||
|
||
def test_preset_veryfast_with_filter(self):
|
||
"""带滤镜场景下也必须使用 veryfast."""
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
|
||
cmd = svc._build_ffmpeg_command(
|
||
input_video="https://example.com/video.mp4",
|
||
b_roll_segments=[],
|
||
filter_complex="overlay=0:0",
|
||
final_label="[v]",
|
||
output_path="/tmp/output.mp4",
|
||
)
|
||
assert "-preset veryfast" in cmd
|
||
assert "-filter_complex" in cmd
|
||
|
||
def test_preset_not_fast(self):
|
||
"""确保不再使用旧的 -preset fast."""
|
||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||
|
||
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
|
||
cmd = svc._build_ffmpeg_command(
|
||
input_video="https://example.com/video.mp4",
|
||
b_roll_segments=[],
|
||
filter_complex="",
|
||
final_label=None,
|
||
output_path="/tmp/output.mp4",
|
||
)
|
||
# 确保是 veryfast 而不是 fast
|
||
assert "-preset veryfast" in cmd
|
||
# 排除 "fast" 单独出现(veryfast 包含 fast 子串,需精确判断)
|
||
parts = cmd.split()
|
||
preset_idx = parts.index("-preset")
|
||
assert parts[preset_idx + 1] == "veryfast"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 优化2: TTS 合成 Celery 异步化
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
def _make_service_with_mocks():
|
||
"""构造 LipsyncService 测试实例及 mock 依赖."""
|
||
from app.services.lipsync_service import LipsyncService
|
||
|
||
db = MagicMock()
|
||
client = MagicMock()
|
||
client.is_available = True
|
||
client.submit_lipsync.return_value = {
|
||
"success": True,
|
||
"task_id": "mk-1",
|
||
"request_id": "req-1",
|
||
}
|
||
cosy = MagicMock()
|
||
cosy.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://tts/raw.mp3",
|
||
"request_id": "tts-req",
|
||
"audio_duration": 3.0,
|
||
}
|
||
svc = LipsyncService(db, client=client, cosyvoice_service=cosy, voice_clone_repo=MagicMock())
|
||
# _resolve_voice_id 默认原样返回(repo.get 返回 None)
|
||
svc._voice_clone_repo.get.return_value = None
|
||
return svc, client, cosy
|
||
|
||
|
||
class TestCreateJobAsyncTTS:
|
||
"""验证 TTS 模式改为 Celery 异步后的行为."""
|
||
|
||
def test_tts_mode_returns_tts_processing_status(self):
|
||
"""TTS 模式下 create_job 立即返回,状态为 tts_processing."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
|
||
mock_task.apply_async = MagicMock()
|
||
|
||
job = svc.create_job(
|
||
user_id="user-1",
|
||
video_url="https://example.com/video.mp4",
|
||
voice_id="longxiaochun_v3",
|
||
script_text="大家好",
|
||
speed=1.0,
|
||
emotion="",
|
||
)
|
||
|
||
assert job.status == "tts_processing"
|
||
|
||
def test_tts_mode_dispatches_celery_task(self):
|
||
"""TTS 模式必须 dispatch Celery 异步任务."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
|
||
mock_task.apply_async = MagicMock()
|
||
|
||
svc.create_job(
|
||
user_id="user-1",
|
||
video_url="https://example.com/video.mp4",
|
||
voice_id="v-1",
|
||
script_text="测试文本",
|
||
)
|
||
|
||
mock_task.apply_async.assert_called_once()
|
||
call_kwargs = mock_task.apply_async.call_args
|
||
args = call_kwargs.kwargs.get("args") or call_kwargs[1].get("args", call_kwargs[0][0] if call_kwargs[0] else ())
|
||
assert args[1] == "user-1" # user_id
|
||
assert args[2] == "v-1" # voice_id
|
||
assert args[3] == "测试文本" # script_text
|
||
|
||
def test_tts_mode_celery_dispatch_failure_still_creates_job(self):
|
||
"""Celery dispatch 失败时,job 记录已创建,状态保持 tts_processing."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
|
||
mock_task.apply_async = MagicMock(side_effect=Exception("Celery broker down"))
|
||
|
||
job = svc.create_job(
|
||
user_id="user-1",
|
||
video_url="https://example.com/video.mp4",
|
||
voice_id="v-1",
|
||
script_text="测试文本",
|
||
)
|
||
|
||
# job 已创建
|
||
assert job is not None
|
||
assert job.status == "tts_processing"
|
||
# MediaKit 未被调用
|
||
client.submit_lipsync.assert_not_called()
|
||
|
||
def test_tts_mode_voice_validation_still_sync(self):
|
||
"""TTS 模式下音色校验仍在 HTTP 请求中同步执行."""
|
||
from app.services.mediakit_client import MediaKitError
|
||
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
# 模拟音色属于其他用户
|
||
other_profile = MagicMock()
|
||
other_profile.user_id = "user-other"
|
||
svc._voice_clone_repo.get.return_value = other_profile
|
||
|
||
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit"):
|
||
with pytest.raises(MediaKitError) as exc:
|
||
svc.create_job(
|
||
user_id="user-1",
|
||
video_url="https://example.com/video.mp4",
|
||
voice_id="clone-profile-id",
|
||
script_text="测试",
|
||
)
|
||
assert exc.value.code == "VoiceForbidden"
|
||
|
||
def test_tts_mode_missing_input_raises_immediately(self):
|
||
"""缺少 voice_id 或 script_text 时立即报错,不 dispatch Celery 任务."""
|
||
from app.services.mediakit_client import MediaKitError
|
||
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit") as mock_task:
|
||
mock_task.delay = MagicMock()
|
||
|
||
with pytest.raises(MediaKitError) as exc:
|
||
svc.create_job(
|
||
user_id="user-1",
|
||
video_url="https://example.com/video.mp4",
|
||
# 缺少 voice_id 和 script_text
|
||
)
|
||
assert exc.value.code == "InvalidInput"
|
||
|
||
# Celery 任务未被 dispatch
|
||
mock_task.delay.assert_not_called()
|
||
# TTS 和 MediaKit 均未调用
|
||
cosy.submit_synthesize_task.assert_not_called()
|
||
client.submit_lipsync.assert_not_called()
|
||
|
||
|
||
class TestCreateJobDirectAudio:
|
||
"""验证直接音频模式不受异步化影响."""
|
||
|
||
def test_direct_audio_still_submits_synchronously(self):
|
||
"""直接音频模式仍然同步提交 MediaKit,状态为 submitted."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit") as mock_task:
|
||
mock_task.delay = MagicMock()
|
||
|
||
job = svc.create_job(
|
||
user_id="user-1",
|
||
video_url="https://example.com/video.mp4",
|
||
audio_url="https://example.com/audio.mp3",
|
||
)
|
||
|
||
assert job.status == "submitted"
|
||
assert job.mediakit_task_id == "mk-1"
|
||
client.submit_lipsync.assert_called_once()
|
||
# TTS Celery 任务不应被调用
|
||
mock_task.delay.assert_not_called()
|
||
|
||
def test_direct_audio_skips_tts(self):
|
||
"""直接音频模式不调用 CosyVoice TTS."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
job = svc.create_job(
|
||
user_id="user-1",
|
||
video_url="https://example.com/video.mp4",
|
||
audio_url="https://example.com/audio.mp3",
|
||
)
|
||
|
||
cosy.submit_synthesize_task.assert_not_called()
|
||
call_kwargs = client.submit_lipsync.call_args
|
||
assert call_kwargs.kwargs["audio_url"] == "https://example.com/audio.mp3"
|
||
|
||
|
||
class TestCancelJobTtsProcessing:
|
||
"""验证 tts_processing 状态的任务可以被取消."""
|
||
|
||
def test_cancel_tts_processing(self):
|
||
"""tts_processing 状态的任务可以成功取消."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.status = "tts_processing"
|
||
mock_job.id = "job-1"
|
||
svc.get_job = MagicMock(return_value=mock_job)
|
||
|
||
result = svc.cancel_job("job-1", "user-1")
|
||
assert result.status == "cancelled"
|
||
|
||
def test_cancel_pending_still_works(self):
|
||
"""pending 状态仍可取消."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.status = "pending"
|
||
mock_job.id = "job-1"
|
||
svc.get_job = MagicMock(return_value=mock_job)
|
||
|
||
result = svc.cancel_job("job-1", "user-1")
|
||
assert result.status == "cancelled"
|
||
|
||
def test_cancel_submitted_still_works(self):
|
||
"""submitted 状态仍可取消."""
|
||
svc, client, cosy = _make_service_with_mocks()
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.status = "submitted"
|
||
mock_job.id = "job-1"
|
||
svc.get_job = MagicMock(return_value=mock_job)
|
||
|
||
result = svc.cancel_job("job-1", "user-1")
|
||
assert result.status == "cancelled"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# lipsync_tts.py — Celery 异步任务单元测试
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
import sys
|
||
import types
|
||
|
||
|
||
class _FakeQuery:
|
||
"""模拟 SQLAlchemy query.filter().first() 链式调用."""
|
||
|
||
def __init__(self, job):
|
||
self._job = job
|
||
|
||
def filter(self, *args, **kwargs):
|
||
return self
|
||
|
||
def first(self):
|
||
return self._job
|
||
|
||
|
||
def _make_fake_job(**kwargs):
|
||
"""构造可 setattr 的 job 记录."""
|
||
job = MagicMock()
|
||
job.id = kwargs.get("job_id", "job-1")
|
||
job.user_id = kwargs.get("user_id", "user-1")
|
||
job.status = kwargs.get("status", "tts_processing")
|
||
job.audio_url = kwargs.get("audio_url", "")
|
||
job.video_url = kwargs.get("video_url", "https://oss/video.mp4")
|
||
job.mediakit_task_id = kwargs.get("mediakit_task_id", "")
|
||
job.enable_video_loop = kwargs.get("enable_video_loop", False)
|
||
job.error_code = ""
|
||
job.error_message = ""
|
||
job.submitted_at = None
|
||
job.updated_at = None
|
||
return job
|
||
|
||
|
||
def _build_session(job):
|
||
"""构造 mock DB session + factory. 返回 (session, factory_patch_ctx_value)."""
|
||
session = MagicMock()
|
||
session.query.return_value = _FakeQuery(job)
|
||
session.commit = MagicMock()
|
||
session.close = MagicMock()
|
||
factory = MagicMock(return_value=session)
|
||
return session, factory
|
||
|
||
|
||
def _apply_all_patches(
|
||
*,
|
||
job=None,
|
||
cosyvoice_service=None,
|
||
cosyvoice_side_effect=None,
|
||
cosyvoice_error=None,
|
||
download_bytes=b"AUDIO",
|
||
download_error=None,
|
||
storage=None,
|
||
mk_client=None,
|
||
mk_submit_return=None,
|
||
mk_submit_error=None,
|
||
):
|
||
"""统一构造测试需要的 patch 列表.
|
||
|
||
lipsync_tts.run() 在函数体内部懒 import 多个模块,通过 sys.modules 注入
|
||
伪造包路径避免真实导入;对存在的模块用 patch() 替换返回值/side_effect。
|
||
"""
|
||
# 构造不存在的 database 模块
|
||
fake_db_mod = types.ModuleType("packages.adapters.sqlalchemy_impl.database")
|
||
session, factory = _build_session(job)
|
||
fake_db_mod.SessionLocal = factory
|
||
|
||
patches = [
|
||
patch.dict(sys.modules, {"packages.adapters.sqlalchemy_impl.database": fake_db_mod}),
|
||
patch(
|
||
"app.services.lipsync_service.LipsyncService._sign_media_url",
|
||
side_effect=lambda url: url + "?signed" if url else url,
|
||
),
|
||
]
|
||
|
||
# CosyVoice
|
||
if cosyvoice_service is not None:
|
||
cosy_instance = cosyvoice_service
|
||
else:
|
||
cosy_instance = MagicMock()
|
||
if cosyvoice_side_effect is not None:
|
||
cosy_instance.submit_synthesize_task.side_effect = cosyvoice_side_effect
|
||
elif cosyvoice_error is not None:
|
||
cosy_instance.submit_synthesize_task.side_effect = cosyvoice_error
|
||
else:
|
||
cosy_instance.submit_synthesize_task.return_value = {"audio_url": "https://tts/raw.mp3"}
|
||
patches.append(patch("packages.application.cosyvoice_service.CosyVoiceService", return_value=cosy_instance))
|
||
|
||
# safe_download_bytes
|
||
if download_error is not None:
|
||
patches.append(patch("packages.shared.url_security.safe_download_bytes", side_effect=download_error))
|
||
else:
|
||
patches.append(patch("packages.shared.url_security.safe_download_bytes", return_value=download_bytes))
|
||
|
||
# Storage
|
||
if storage is None:
|
||
storage = MagicMock()
|
||
storage.public_url = "https://oss.example.com"
|
||
storage.upload_file.return_value = "https://oss.example.com/tts.mp3"
|
||
patches.append(patch("packages.shared.storage.get_shared_storage_service", return_value=storage))
|
||
|
||
# MediaKit client
|
||
if mk_client is not None:
|
||
patches.append(patch("app.services.mediakit_client.get_mediakit_client", return_value=mk_client))
|
||
else:
|
||
client = MagicMock()
|
||
if mk_submit_error is not None:
|
||
client.submit_lipsync.side_effect = mk_submit_error
|
||
else:
|
||
client.submit_lipsync.return_value = mk_submit_return or {"task_id": "mk-1"}
|
||
patches.append(patch("app.services.mediakit_client.get_mediakit_client", return_value=client))
|
||
|
||
return session, patches
|
||
|
||
|
||
class TestTtsSynthesizeAndSubmit:
|
||
"""测试 Celery 任务 tts_synthesize_and_submit.run 的所有分支."""
|
||
|
||
def test_job_not_found_returns_early(self):
|
||
"""Job 不存在 → 日志报错直接返回,不抛异常."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
session, patches = _apply_all_patches(job=None)
|
||
entered = [p.__enter__() for p in patches]
|
||
try:
|
||
tts_synthesize_and_submit.run("missing-job", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
session.commit.assert_not_called()
|
||
session.close.assert_called_once()
|
||
|
||
def test_cancelled_job_skipped(self):
|
||
"""Job 已 cancelled → 跳过不处理,不调用 TTS/MediaKit."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job(status="cancelled")
|
||
session, patches = _apply_all_patches(job=job)
|
||
entered = [p.__enter__() for p in patches]
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
session.commit.assert_not_called()
|
||
session.close.assert_called_once()
|
||
assert job.status == "cancelled"
|
||
|
||
def test_happy_path_tts_to_mediakit(self):
|
||
"""正常流程:TTS 合成 → 下载 → OSS → 签名 → 提交 MediaKit → submitted."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job(status="tts_processing")
|
||
storage = MagicMock()
|
||
storage.public_url = "https://oss.example.com"
|
||
storage.upload_file.return_value = "https://oss.example.com/lipsync-tts/u/j.mp3"
|
||
|
||
session, patches = _apply_all_patches(
|
||
job=job,
|
||
storage=storage,
|
||
mk_submit_return={"task_id": "mk-999"},
|
||
)
|
||
for p in patches:
|
||
p.__enter__()
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
assert job.audio_url == "https://oss.example.com/lipsync-tts/u/j.mp3"
|
||
assert job.mediakit_task_id == "mk-999"
|
||
assert job.status == "submitted"
|
||
assert job.submitted_at is not None
|
||
session.close.assert_called_once()
|
||
|
||
def test_tts_cosyvoice_error_marks_failed(self):
|
||
"""CosyVoiceError → 标记 failed,error_code=TTSSynthesisFailed."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
from packages.application.cosyvoice_service import CosyVoiceError
|
||
|
||
job = _make_fake_job(status="tts_processing")
|
||
session, patches = _apply_all_patches(
|
||
job=job,
|
||
cosyvoice_error=CosyVoiceError("TTS 服务异常"),
|
||
)
|
||
for p in patches:
|
||
p.__enter__()
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "TTSSynthesisFailed"
|
||
assert "TTS 合成失败" in job.error_message
|
||
session.close.assert_called_once()
|
||
|
||
def test_tts_value_error_marks_failed(self):
|
||
"""ValueError → 标记 failed,error_code=TTSInvalidParam."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job(status="tts_processing")
|
||
session, patches = _apply_all_patches(
|
||
job=job,
|
||
cosyvoice_error=ValueError("speed 参数非法"),
|
||
)
|
||
for p in patches:
|
||
p.__enter__()
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "TTSInvalidParam"
|
||
assert "TTS 参数错误" in job.error_message
|
||
session.close.assert_called_once()
|
||
|
||
def test_tts_no_audio_url_marks_failed(self):
|
||
"""TTS 返回空 audio_url → 标记 failed,error_code=TTSNoAudio."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job(status="tts_processing")
|
||
cosy = MagicMock()
|
||
cosy.submit_synthesize_task.return_value = {"audio_url": ""}
|
||
session, patches = _apply_all_patches(job=job, cosyvoice_service=cosy)
|
||
for p in patches:
|
||
p.__enter__()
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "TTSNoAudio"
|
||
session.close.assert_called_once()
|
||
|
||
def test_oss_upload_failure_falls_back_to_temp_url(self):
|
||
"""OSS 上传失败 → 回退临时 URL,继续提交 MediaKit."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job(status="tts_processing")
|
||
storage = MagicMock()
|
||
storage.public_url = "https://oss.example.com"
|
||
storage.upload_file.side_effect = Exception("OSS 上传超时")
|
||
session, patches = _apply_all_patches(
|
||
job=job,
|
||
storage=storage,
|
||
mk_submit_return={"task_id": "mk-77"},
|
||
)
|
||
for p in patches:
|
||
p.__enter__()
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
# 回退到临时 URL
|
||
assert job.audio_url == "https://tts/raw.mp3"
|
||
assert job.status == "submitted"
|
||
assert job.mediakit_task_id == "mk-77"
|
||
session.close.assert_called_once()
|
||
|
||
def test_mediakit_submit_failure_marks_failed(self):
|
||
"""MediaKit 提交失败(MediaKitError)→ 标记 failed."""
|
||
from app.services.mediakit_client import MediaKitError
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job(status="tts_processing")
|
||
err = MediaKitError("GPU 不可用", code="MediaKitUnavailable")
|
||
session, patches = _apply_all_patches(
|
||
job=job,
|
||
mk_submit_error=err,
|
||
)
|
||
for p in patches:
|
||
p.__enter__()
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "MediaKitUnavailable"
|
||
session.close.assert_called_once()
|
||
|
||
def test_top_level_exception_marks_async_task_error(self):
|
||
"""顶层意外异常 → except 分支回写 failed,error_code=AsyncTaskError."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job(status="tts_processing")
|
||
# 不调用 _apply_all_patches,手动构造所有 patch,让 CosyVoiceService 抛异常
|
||
fake_db_mod = types.ModuleType("packages.adapters.sqlalchemy_impl.database")
|
||
session_mock = MagicMock()
|
||
session_mock.query.return_value = _FakeQuery(job)
|
||
session_mock.commit = MagicMock()
|
||
session_mock.close = MagicMock()
|
||
fake_db_mod.SessionLocal = MagicMock(return_value=session_mock)
|
||
|
||
all_patches = [
|
||
patch.dict(sys.modules, {"packages.adapters.sqlalchemy_impl.database": fake_db_mod}),
|
||
patch(
|
||
"app.services.lipsync_service.LipsyncService._sign_media_url",
|
||
side_effect=lambda url: url + "?signed" if url else url,
|
||
),
|
||
patch(
|
||
"packages.application.cosyvoice_service.CosyVoiceService",
|
||
side_effect=RuntimeError("unexpected init failure"),
|
||
),
|
||
patch("packages.shared.url_security.safe_download_bytes", return_value=b"AUDIO"),
|
||
patch("packages.shared.storage.get_shared_storage_service", return_value=MagicMock()),
|
||
patch("app.services.mediakit_client.get_mediakit_client", return_value=MagicMock()),
|
||
]
|
||
for p in all_patches:
|
||
p.__enter__()
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
for p in reversed(all_patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "AsyncTaskError"
|
||
assert "TTS 异步任务执行异常" in job.error_message
|
||
session_mock.close.assert_called_once()
|