695a491c5d
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 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m25s
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Style (push) Successful in 6m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m23s
CI/CD Pipeline / Validate - Security (push) Successful in 10m16s
CI/CD Pipeline / Unit Tests (push) Successful in 10m35s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 35h37m35s
CI/CD Pipeline / Build Production API Image (push) Failing after 35h37m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 35h48m10s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 35h37m2s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 35h47m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 35h47m37s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 35h42m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 35h36m58s
CI/CD Pipeline / Build Production Web Image (push) Failing after 35h37m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 35h47m37s
CI/CD Pipeline / Deploy Production (push) Failing after 35h36m58s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 35h42m57s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 35h47m42s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 36h18m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
977 lines
40 KiB
Python
977 lines
40 KiB
Python
"""AI 数字人口型 TTS Celery 异步任务 — 单元测试.
|
||
|
||
覆盖 lipsync_tts.py 的全部主要分支:
|
||
- Job 不存在/cancelled/正常/异常路径
|
||
- TTS 合成、音频下载、OSS 上传、MediaKit 提交
|
||
- CosyVoiceError/ValueError/MediaKitError/顶层异常等错误码
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import types
|
||
from types import ModuleType
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||
|
||
|
||
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)."""
|
||
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。
|
||
"""
|
||
# SessionLocal 通过懒探测获取(Worker 用 worker_app.db,API 用 app.db),
|
||
# 测试环境里两个模块都能被真实导入,必须同时 mock 保证用的是 fake session。
|
||
fake_app_db = ModuleType("app.db")
|
||
fake_worker_db = ModuleType("worker_app.db")
|
||
session, factory = _build_session(job)
|
||
fake_app_db.SessionLocal = factory
|
||
fake_worker_db.SessionLocal = factory
|
||
|
||
patches = [
|
||
patch.dict(sys.modules, {"app.db": fake_app_db, "worker_app.db": fake_worker_db}),
|
||
patch(
|
||
"app.tasks.lipsync_tts._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)
|
||
|
||
# cancelled 不应 commit,不应触发 TTS/MediaKit
|
||
session.commit.assert_not_called()
|
||
session.close.assert_called_once()
|
||
|
||
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()
|
||
mk_client = MagicMock()
|
||
mk_client.submit_lipsync.return_value = {"task_id": "mk-999"}
|
||
session, patches = _apply_all_patches(job=job, mk_client=mk_client)
|
||
entered = [p.__enter__() for p in patches]
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好世界", 1.0, "happy")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
assert job.status == "submitted"
|
||
assert job.mediakit_task_id == "mk-999"
|
||
assert job.error_code == ""
|
||
mk_client.submit_lipsync.assert_called_once()
|
||
call_kwargs = mk_client.submit_lipsync.call_args.kwargs
|
||
assert call_kwargs["client_token"] == "job-1"
|
||
# CosyVoice 临时 URL 经 _sign_media_url 透传(mock 统一追加 ?signed),
|
||
# 自家 OSS 才会被重签,外部 URL 原样透传;job.audio_url 存原始临时 URL
|
||
assert call_kwargs["audio_url"] == "https://tts/raw.mp3?signed"
|
||
assert job.audio_url == "https://tts/raw.mp3"
|
||
session.commit.assert_called()
|
||
session.close.assert_called_once()
|
||
|
||
def test_cosyvoice_error_marks_tts_synthesis_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()
|
||
session, patches = _apply_all_patches(job=job, cosyvoice_error=CosyVoiceError("tts boom"))
|
||
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)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "TTSSynthesisFailed"
|
||
session.close.assert_called_once()
|
||
|
||
def test_value_error_marks_tts_invalid_param(self):
|
||
"""ValueError(参数错误)→ failed, error_code=TTSInvalidParam."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job()
|
||
session, patches = _apply_all_patches(job=job, cosyvoice_side_effect=ValueError("bad param"))
|
||
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)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "TTSInvalidParam"
|
||
session.close.assert_called_once()
|
||
|
||
def test_no_audio_url_marks_tts_no_audio(self):
|
||
"""TTS 返回空 audio_url → failed, error_code=TTSNoAudio."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job()
|
||
cosy = MagicMock()
|
||
cosy.submit_synthesize_task.return_value = {"audio_url": ""}
|
||
session, patches = _apply_all_patches(job=job, cosyvoice_service=cosy)
|
||
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)
|
||
|
||
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,仍然 submitted."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job()
|
||
storage = MagicMock()
|
||
storage.public_url = "https://oss.example.com"
|
||
storage.upload_file.side_effect = RuntimeError("oss down")
|
||
mk_client = MagicMock()
|
||
mk_client.submit_lipsync.return_value = {"task_id": "mk-7"}
|
||
session, patches = _apply_all_patches(job=job, storage=storage, mk_client=mk_client)
|
||
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)
|
||
|
||
# 上传失败后 audio_url 回退为临时 TTS URL,仍继续提交到 MediaKit
|
||
assert job.audio_url == "https://tts/raw.mp3"
|
||
assert job.status == "submitted"
|
||
assert job.mediakit_task_id == "mk-7"
|
||
mk_client.submit_lipsync.assert_called_once()
|
||
session.close.assert_called_once()
|
||
|
||
def test_mediakit_error_marks_mediakit_unavailable(self):
|
||
"""MediaKit 提交失败 → failed, error_code=MediaKitUnavailable."""
|
||
from app.services.mediakit_client import MediaKitError
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job()
|
||
mk_err = MediaKitError("mk down", code="MediaKitUnavailable")
|
||
session, patches = _apply_all_patches(job=job, mk_submit_error=mk_err)
|
||
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)
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "MediaKitUnavailable"
|
||
session.close.assert_called_once()
|
||
|
||
def test_top_level_exception_marks_async_task_error(self):
|
||
"""顶层未预期异常 → failed, error_code=AsyncTaskError."""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
job = _make_fake_job()
|
||
fake_app_db = ModuleType("app.db")
|
||
fake_worker_db = ModuleType("worker_app.db")
|
||
session, factory = _build_session(job)
|
||
fake_app_db.SessionLocal = factory
|
||
fake_worker_db.SessionLocal = factory
|
||
|
||
# CosyVoiceService 在 __init__ 抛 RuntimeError(非 CosyVoiceError/ValueError)
|
||
fake_cosy_mod = ModuleType("packages.application.cosyvoice_service")
|
||
|
||
class _CosyVoiceErrorForTest(Exception):
|
||
pass
|
||
|
||
class _BoomService:
|
||
def __init__(self):
|
||
raise RuntimeError("top-level boom")
|
||
|
||
fake_cosy_mod.CosyVoiceError = _CosyVoiceErrorForTest
|
||
fake_cosy_mod.CosyVoiceService = _BoomService
|
||
|
||
with patch.dict(
|
||
sys.modules,
|
||
{
|
||
"app.db": fake_app_db,
|
||
"worker_app.db": fake_worker_db,
|
||
"packages.application.cosyvoice_service": fake_cosy_mod,
|
||
},
|
||
):
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
|
||
assert job.status == "failed"
|
||
assert job.error_code == "AsyncTaskError"
|
||
session.close.assert_called()
|
||
|
||
|
||
class TestSignMediaUrl:
|
||
"""覆盖模块内 _sign_media_url 的所有分支(CI 增量覆盖率需要)."""
|
||
|
||
def test_empty_url_returns_empty(self):
|
||
from app.tasks.lipsync_tts import _sign_media_url
|
||
|
||
assert _sign_media_url("") == ""
|
||
assert _sign_media_url(None) is None
|
||
|
||
def test_own_oss_url_signed(self):
|
||
"""自家 OSS URL → 调用 storage.get_download_url 签名."""
|
||
from app.tasks.lipsync_tts import _sign_media_url
|
||
|
||
fake_storage = MagicMock()
|
||
fake_storage.public_url = "https://oss.example.com/"
|
||
fake_storage.get_download_url.return_value = "https://oss.example.com/a?sig=xyz"
|
||
|
||
with patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage):
|
||
result = _sign_media_url("https://oss.example.com/lipsync/a.mp3")
|
||
|
||
assert result == "https://oss.example.com/a?sig=xyz"
|
||
fake_storage.get_download_url.assert_called_once()
|
||
|
||
def test_external_url_passthrough(self):
|
||
"""外部 URL(不是自家 OSS host)→ 原样透传,不签名."""
|
||
from app.tasks.lipsync_tts import _sign_media_url
|
||
|
||
fake_storage = MagicMock()
|
||
fake_storage.public_url = "https://oss.example.com/"
|
||
|
||
with patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage):
|
||
result = _sign_media_url("https://tts.example.com/raw.mp3")
|
||
|
||
assert result == "https://tts.example.com/raw.mp3"
|
||
fake_storage.get_download_url.assert_not_called()
|
||
|
||
def test_storage_exception_falls_back(self):
|
||
"""storage 调用异常 → 降级原样返回,不抛错."""
|
||
from app.tasks.lipsync_tts import _sign_media_url
|
||
|
||
with patch(
|
||
"packages.shared.storage.get_shared_storage_service",
|
||
side_effect=RuntimeError("storage down"),
|
||
):
|
||
result = _sign_media_url("https://oss.example.com/a.mp3")
|
||
|
||
assert result == "https://oss.example.com/a.mp3"
|
||
|
||
def test_no_public_url_passthrough(self):
|
||
"""storage.public_url 为空 → 原样透传."""
|
||
from app.tasks.lipsync_tts import _sign_media_url
|
||
|
||
fake_storage = MagicMock()
|
||
fake_storage.public_url = ""
|
||
|
||
with patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage):
|
||
result = _sign_media_url("https://anything.example.com/a.mp3")
|
||
|
||
assert result == "https://anything.example.com/a.mp3"
|
||
fake_storage.get_download_url.assert_not_called()
|
||
|
||
|
||
class TestPersistOutputVideoTask:
|
||
"""persist_output_video_task:下载 MediaKit 临时视频 → 上传自有 OSS → 更新 DB."""
|
||
|
||
def _make_persist_job(self, **kwargs):
|
||
job = MagicMock()
|
||
job.id = kwargs.get("job_id", "job-1")
|
||
job.user_id = kwargs.get("user_id", "user-1")
|
||
job.output_video_url = kwargs.get("output_video_url", "https://temp.mk/output.mp4")
|
||
job.updated_at = None
|
||
return job
|
||
|
||
def _persist_patches(self, *, job, video_bytes=b"FAKEMP4", download_side_effect=None, upload_url=None):
|
||
"""统一 patch:SessionLocal、httpx.Client、storage、_sign_media_url."""
|
||
fake_app_db = ModuleType("app.db")
|
||
fake_worker_db = ModuleType("worker_app.db")
|
||
session, factory = _build_session(job)
|
||
fake_app_db.SessionLocal = factory
|
||
fake_worker_db.SessionLocal = factory
|
||
|
||
# httpx.Client 上下文管理器
|
||
fake_response = MagicMock()
|
||
fake_response.content = video_bytes
|
||
fake_response.raise_for_status = MagicMock()
|
||
fake_client = MagicMock()
|
||
fake_client.get.return_value = fake_response
|
||
fake_client_cm = MagicMock()
|
||
fake_client_cm.__enter__ = MagicMock(return_value=fake_client)
|
||
fake_client_cm.__exit__ = MagicMock(return_value=False)
|
||
FakeHttpxClient = MagicMock(return_value=fake_client_cm)
|
||
if download_side_effect is not None:
|
||
fake_client.get.side_effect = download_side_effect
|
||
|
||
# storage
|
||
storage = MagicMock()
|
||
storage.public_url = "https://oss.example.com/"
|
||
storage.upload_file.return_value = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
|
||
# _sign_media_url 内部会调 storage.get_download_url,必须mock返回字符串
|
||
_upload_url = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
|
||
storage.get_download_url.return_value = _upload_url + "?signed"
|
||
|
||
fake_httpx = ModuleType("httpx")
|
||
fake_httpx.Client = FakeHttpxClient
|
||
|
||
patches = [
|
||
patch.dict(
|
||
sys.modules,
|
||
{"app.db": fake_app_db, "worker_app.db": fake_worker_db, "httpx": fake_httpx},
|
||
),
|
||
patch("packages.shared.storage.get_shared_storage_service", return_value=storage),
|
||
patch("app.tasks.lipsync_tts._sign_media_url", side_effect=lambda url: url + "?signed" if url else url),
|
||
]
|
||
return session, fake_client, storage, patches
|
||
|
||
def test_success_download_upload_updates_db(self):
|
||
"""正常路径:下载 temp_url → 上传 OSS → 签名 → 写回 DB commit."""
|
||
from app.tasks.lipsync_tts import persist_output_video_task
|
||
|
||
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
|
||
session, fake_client, storage, patches = self._persist_patches(
|
||
job=job, video_bytes=b"VIDEODATA", upload_url="https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
|
||
)
|
||
entered = [p.__enter__() for p in patches]
|
||
try:
|
||
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
fake_client.get.assert_called_once_with("https://temp.mk/x.mp4")
|
||
storage.upload_file.assert_called_once()
|
||
call_args = storage.upload_file.call_args.args
|
||
# 上传的 key 必须是 lipsync-outputs/{user_id}/{job_id}.mp4
|
||
assert call_args[1] == "lipsync-outputs/user-1/job-1.mp4"
|
||
# upload_file 返回永久 URL,再被 _sign_media_url 追加 ?signed
|
||
assert job.output_video_url == "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4?signed"
|
||
assert job.updated_at is not None
|
||
session.commit.assert_called_once()
|
||
session.close.assert_called_once()
|
||
|
||
def test_download_failure_keeps_temp_url_no_commit(self):
|
||
"""下载失败(raise)→ 记录 warning、保留 temp_url、不抛异常."""
|
||
from app.tasks.lipsync_tts import persist_output_video_task
|
||
|
||
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
|
||
session, fake_client, storage, patches = self._persist_patches(
|
||
job=job, download_side_effect=RuntimeError("network down")
|
||
)
|
||
entered = [p.__enter__() for p in patches]
|
||
try:
|
||
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
storage.upload_file.assert_not_called()
|
||
# output_video_url 保持原值(temp_url)
|
||
assert job.output_video_url == "https://temp.mk/x.mp4"
|
||
# 内层 except 不会 commit
|
||
# 注:若内部发生 commit 说明测试失败
|
||
session.close.assert_called_once()
|
||
|
||
def test_empty_temp_url_skips_persist(self):
|
||
"""temp_url 为空 → 直接返回,不下载不上传."""
|
||
from app.tasks.lipsync_tts import persist_output_video_task
|
||
|
||
job = self._make_persist_job(output_video_url="")
|
||
session, fake_client, storage, patches = self._persist_patches(job=job)
|
||
entered = [p.__enter__() for p in patches]
|
||
try:
|
||
persist_output_video_task("job-1", "user-1", "")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
fake_client.get.assert_not_called()
|
||
storage.upload_file.assert_not_called()
|
||
session.commit.assert_not_called()
|
||
session.close.assert_called_once()
|
||
|
||
def test_job_not_found_returns_early(self):
|
||
"""DB 中找不到 job → 直接返回,不抛错."""
|
||
from app.tasks.lipsync_tts import persist_output_video_task
|
||
|
||
session, fake_client, storage, patches = self._persist_patches(job=None)
|
||
entered = [p.__enter__() for p in patches]
|
||
try:
|
||
persist_output_video_task("missing", "user-1", "https://temp.mk/x.mp4")
|
||
finally:
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
|
||
fake_client.get.assert_not_called()
|
||
storage.upload_file.assert_not_called()
|
||
session.commit.assert_not_called()
|
||
session.close.assert_called_once()
|
||
|
||
|
||
class TestLipsyncServiceRefreshCompletedAsyncPersist:
|
||
"""refresh_job_status 在 completed 分支异步转存的单元测试(补 0% 覆盖的 316~335 行)."""
|
||
|
||
def test_refresh_completed_dispatches_persist_task(self):
|
||
"""completed 分支:设置 temp_url → commit → dispatch persist_output_video_task.apply_async."""
|
||
from app.services.lipsync_service import LipsyncService
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.id = "job-1"
|
||
mock_job.user_id = "user-1"
|
||
mock_job.mediakit_task_id = "mk-1"
|
||
mock_job.status = "submitted"
|
||
mock_job.output_video_url = ""
|
||
mock_job.output_duration = 0.0
|
||
|
||
mock_db = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_filter = MagicMock()
|
||
mock_filter.first.return_value = mock_job
|
||
mock_query.filter.return_value = mock_filter
|
||
mock_db.query.return_value = mock_query
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "completed",
|
||
"result": {"video_url": "https://temp.mk/out.mp4", "duration": 25.5},
|
||
}
|
||
|
||
fake_persist_task = MagicMock()
|
||
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
|
||
with patch.dict("sys.modules", {}):
|
||
# 直接 patch 懒 import 路径
|
||
with patch("app.tasks.lipsync_tts.persist_output_video_task", fake_persist_task, create=False):
|
||
# 但懒 import 发生在函数内部 from app.tasks.lipsync_tts import persist_output_video_task
|
||
# 通过 patch sys.modules 的方式提供
|
||
import sys as _sys
|
||
|
||
fake_mod = MagicMock()
|
||
fake_mod.persist_output_video_task = fake_persist_task
|
||
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
|
||
try:
|
||
result = svc.refresh_job_status("job-1", "user-1")
|
||
finally:
|
||
_sys.modules.pop("app.tasks.lipsync_tts", None)
|
||
|
||
assert result.status == "completed"
|
||
assert result.output_video_url == "https://temp.mk/out.mp4"
|
||
assert result.output_duration == 25.5
|
||
mock_db.commit.assert_called()
|
||
# 必须在 commit 之后 dispatch
|
||
fake_persist_task.apply_async.assert_called_once()
|
||
kwargs = fake_persist_task.apply_async.call_args.kwargs
|
||
assert kwargs["args"] == ("job-1", "user-1", "https://temp.mk/out.mp4")
|
||
|
||
def test_refresh_completed_dispatch_exception_does_not_break_return(self):
|
||
"""apply_async 抛异常(如 Celery 不可用)→ 捕获 warning,仍返回 completed job."""
|
||
from app.services.lipsync_service import LipsyncService
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.id = "job-2"
|
||
mock_job.user_id = "user-1"
|
||
mock_job.mediakit_task_id = "mk-2"
|
||
mock_job.status = "submitted"
|
||
mock_job.output_video_url = ""
|
||
mock_job.output_duration = 0.0
|
||
|
||
mock_db = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_filter = MagicMock()
|
||
mock_filter.first.return_value = mock_job
|
||
mock_query.filter.return_value = mock_filter
|
||
mock_db.query.return_value = mock_query
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "completed",
|
||
"result": {"video_url": "https://temp.mk/out2.mp4", "duration": 10.0},
|
||
}
|
||
|
||
fake_persist_task = MagicMock()
|
||
fake_persist_task.apply_async.side_effect = ConnectionError("celery down")
|
||
|
||
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
|
||
import sys as _sys
|
||
|
||
fake_mod = MagicMock()
|
||
fake_mod.persist_output_video_task = fake_persist_task
|
||
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
|
||
try:
|
||
result = svc.refresh_job_status("job-2", "user-1")
|
||
finally:
|
||
_sys.modules.pop("app.tasks.lipsync_tts", None)
|
||
|
||
# 即便 dispatch 失败,主流程不受影响:仍然返回 completed + temp_url
|
||
assert result.status == "completed"
|
||
assert result.output_video_url == "https://temp.mk/out2.mp4"
|
||
fake_persist_task.apply_async.assert_called_once()
|
||
|
||
|
||
class TestRefreshJobStatusDbRollback:
|
||
"""#1867 三层防御 ②:refresh_job_status DB commit 失败时 rollback 不 raise."""
|
||
|
||
def test_refresh_db_commit_failure_rollback_and_return(self):
|
||
"""commit 抛异常 → rollback,不 raise,返回当前 job 对象让下次轮询重试."""
|
||
from app.services.lipsync_service import LipsyncService
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.id = "job-rollback"
|
||
mock_job.user_id = "u"
|
||
mock_job.mediakit_task_id = "mk"
|
||
mock_job.status = "submitted"
|
||
mock_job.output_video_url = ""
|
||
mock_job.output_duration = 0.0
|
||
|
||
mock_db = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_filter = MagicMock()
|
||
mock_filter.first.return_value = mock_job
|
||
mock_query.filter.return_value = mock_filter
|
||
mock_db.query.return_value = mock_query
|
||
mock_db.commit.side_effect = Exception("DB connection lost")
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "completed",
|
||
"result": {"video_url": "https://temp.mk/out.mp4", "duration": 5.0},
|
||
}
|
||
|
||
# refresh_job_status 内部懒 import persist_output_video_task
|
||
# 用 patch 替换模块属性,create=True 因为原模块里它是 shared_task,patch 后是 MagicMock
|
||
with patch("app.tasks.lipsync_tts.persist_output_video_task", MagicMock(), create=True):
|
||
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
|
||
result_job = svc.refresh_job_status("job-rollback", "u")
|
||
|
||
assert result_job is mock_job
|
||
mock_db.rollback.assert_called()
|
||
|
||
def test_refresh_intermediate_status_commits(self):
|
||
"""中间状态(running/processing 等)→ 更新 status 和 updated_at,正常 commit."""
|
||
from app.services.lipsync_service import LipsyncService
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.id = "job-mid"
|
||
mock_job.user_id = "u"
|
||
mock_job.mediakit_task_id = "mk"
|
||
mock_job.status = "submitted"
|
||
mock_job.output_video_url = ""
|
||
mock_job.output_duration = 0.0
|
||
|
||
mock_db = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_filter = MagicMock()
|
||
mock_filter.first.return_value = mock_job
|
||
mock_query.filter.return_value = mock_filter
|
||
mock_db.query.return_value = mock_query
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {"status": "processing"}
|
||
|
||
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
|
||
result_job = svc.refresh_job_status("job-mid", "u")
|
||
assert result_job is mock_job
|
||
assert mock_job.status == "processing"
|
||
mock_db.commit.assert_called()
|
||
mock_db.rollback.assert_not_called()
|
||
|
||
def test_refresh_failed_commits_error_info(self):
|
||
"""failed 状态 → 写入 error_message/error_code/completed_at,commit."""
|
||
from app.services.lipsync_service import LipsyncService
|
||
|
||
mock_job = MagicMock()
|
||
mock_job.id = "job-fail"
|
||
mock_job.user_id = "u"
|
||
mock_job.mediakit_task_id = "mk"
|
||
mock_job.status = "submitted"
|
||
mock_job.output_video_url = ""
|
||
mock_job.output_duration = 0.0
|
||
|
||
mock_db = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_filter = MagicMock()
|
||
mock_filter.first.return_value = mock_job
|
||
mock_query.filter.return_value = mock_filter
|
||
mock_db.query.return_value = mock_query
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "failed",
|
||
"error": {"code": "BadAudio", "message": "音频损坏"},
|
||
}
|
||
|
||
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
|
||
result_job = svc.refresh_job_status("job-fail", "u")
|
||
assert result_job.status == "failed"
|
||
assert mock_job.error_message == "音频损坏"
|
||
assert mock_job.error_code == "BadAudio"
|
||
mock_db.commit.assert_called()
|
||
|
||
|
||
class TestPollMediakitStatus:
|
||
"""#1867 三层防御 ③:Celery 兜底轮询任务单元测试."""
|
||
|
||
def _make_session(self, job):
|
||
mock_db = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_filter = MagicMock()
|
||
mock_filter.first.return_value = job
|
||
mock_query.filter.return_value = mock_filter
|
||
mock_db.query.return_value = mock_query
|
||
return mock_db
|
||
|
||
def _patch_sessions(self, mock_db):
|
||
"""用 patch.dict 注入 fake SessionLocal 到 worker_app.db 和 app.db."""
|
||
from types import ModuleType
|
||
|
||
fake_app_db = ModuleType("app.db")
|
||
fake_worker_db = ModuleType("worker_app.db")
|
||
fake_sl = MagicMock(return_value=mock_db)
|
||
fake_app_db.SessionLocal = fake_sl
|
||
fake_worker_db.SessionLocal = fake_sl
|
||
fake_worker_pkg = ModuleType("worker_app")
|
||
return patch.dict(
|
||
sys.modules,
|
||
{
|
||
"worker_app": fake_worker_pkg,
|
||
"worker_app.db": fake_worker_db,
|
||
"app.db": fake_app_db,
|
||
},
|
||
)
|
||
|
||
def _run(self, mock_db, job_id="j", user_id="u1", fake_self=None):
|
||
from app.tasks.lipsync_tts import poll_mediakit_status
|
||
|
||
self_ref = fake_self if fake_self is not None else MagicMock()
|
||
real = poll_mediakit_status._get_current_object()
|
||
return real.run.__func__(self_ref, job_id=job_id, user_id=user_id), self_ref
|
||
|
||
def test_poll_job_not_found_returns(self):
|
||
mock_db = self._make_session(None)
|
||
with self._patch_sessions(mock_db):
|
||
self._run(mock_db, job_id="missing")
|
||
mock_db.close.assert_called_once()
|
||
|
||
def test_poll_terminal_completed_skips_mediakit(self):
|
||
mock_job = MagicMock(status="completed", mediakit_task_id="mk-1")
|
||
mock_db = self._make_session(mock_job)
|
||
with self._patch_sessions(mock_db), patch("app.services.mediakit_client.get_mediakit_client") as fg:
|
||
self._run(mock_db)
|
||
fg.assert_not_called()
|
||
mock_db.close.assert_called_once()
|
||
|
||
def test_poll_terminal_failed_skips(self):
|
||
mock_job = MagicMock(status="failed", mediakit_task_id="mk-1")
|
||
mock_db = self._make_session(mock_job)
|
||
with self._patch_sessions(mock_db), patch("app.services.mediakit_client.get_mediakit_client") as fg:
|
||
self._run(mock_db, job_id="j1b")
|
||
fg.assert_not_called()
|
||
|
||
def test_poll_terminal_cancelled_skips(self):
|
||
mock_job = MagicMock(status="cancelled", mediakit_task_id="mk-1")
|
||
mock_db = self._make_session(mock_job)
|
||
with self._patch_sessions(mock_db), patch("app.services.mediakit_client.get_mediakit_client") as fg:
|
||
self._run(mock_db, job_id="j1c")
|
||
fg.assert_not_called()
|
||
|
||
def test_poll_no_mk_task_id_returns(self):
|
||
mock_job = MagicMock(status="submitted", mediakit_task_id=None)
|
||
mock_db = self._make_session(mock_job)
|
||
with self._patch_sessions(mock_db):
|
||
self._run(mock_db, job_id="j6")
|
||
mock_db.close.assert_called_once()
|
||
|
||
def test_poll_succeeded_marks_completed(self):
|
||
mock_job = MagicMock(status="submitted", mediakit_task_id="mk-2", output_video_url="", output_duration=0.0)
|
||
mock_db = self._make_session(mock_job)
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "succeeded",
|
||
"result": {"video_url": "https://temp/x.mp4", "duration": 12.0},
|
||
}
|
||
with (
|
||
self._patch_sessions(mock_db),
|
||
patch("app.services.mediakit_client.get_mediakit_client", return_value=mock_client),
|
||
patch("app.services.lipsync_service.LipsyncService") as MS,
|
||
):
|
||
svc = MS.return_value
|
||
svc._persist_output_video.return_value = "https://oss/x.mp4"
|
||
self._run(mock_db, job_id="j2")
|
||
assert mock_job.status == "completed"
|
||
assert mock_job.output_video_url == "https://oss/x.mp4"
|
||
assert mock_job.output_duration == 12.0
|
||
svc._persist_output_video.assert_called_once_with("https://temp/x.mp4", "j2", "u1")
|
||
mock_db.commit.assert_called()
|
||
|
||
def test_poll_succeeded_persist_fail_fallback(self):
|
||
mock_job = MagicMock(status="submitted", mediakit_task_id="mk-3", output_video_url="", output_duration=0.0)
|
||
mock_db = self._make_session(mock_job)
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "succeeded",
|
||
"result": {"video_url": "https://temp/y.mp4", "duration": 8.0},
|
||
}
|
||
with (
|
||
self._patch_sessions(mock_db),
|
||
patch("app.services.mediakit_client.get_mediakit_client", return_value=mock_client),
|
||
patch("app.services.lipsync_service.LipsyncService") as MS,
|
||
):
|
||
svc = MS.return_value
|
||
svc._persist_output_video.side_effect = Exception("OSS down")
|
||
self._run(mock_db, job_id="j3")
|
||
assert mock_job.status == "completed"
|
||
assert mock_job.output_video_url == "https://temp/y.mp4"
|
||
|
||
def test_poll_failed_marks_failed(self):
|
||
mock_job = MagicMock(status="running", mediakit_task_id="mk-4", error_message="", error_code="")
|
||
mock_db = self._make_session(mock_job)
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "failed",
|
||
"error": {"code": "TaskFailed", "message": "音频错误"},
|
||
}
|
||
with (
|
||
self._patch_sessions(mock_db),
|
||
patch("app.services.mediakit_client.get_mediakit_client", return_value=mock_client),
|
||
):
|
||
self._run(mock_db, job_id="j4")
|
||
assert mock_job.status == "failed"
|
||
assert mock_job.error_message == "音频错误"
|
||
assert mock_job.error_code == "TaskFailed"
|
||
|
||
def test_poll_error_status_marks_failed(self):
|
||
mock_job = MagicMock(status="submitted", mediakit_task_id="mk-4b", error_message="", error_code="")
|
||
mock_db = self._make_session(mock_job)
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {
|
||
"status": "error",
|
||
"error": {"code": "Internal", "message": "内部错误"},
|
||
}
|
||
with (
|
||
self._patch_sessions(mock_db),
|
||
patch("app.services.mediakit_client.get_mediakit_client", return_value=mock_client),
|
||
):
|
||
self._run(mock_db, job_id="j4b")
|
||
assert mock_job.status == "failed"
|
||
assert mock_job.error_message == "内部错误"
|
||
|
||
def test_poll_running_triggers_retry(self):
|
||
mock_job = MagicMock(status="submitted", mediakit_task_id="mk-5")
|
||
mock_db = self._make_session(mock_job)
|
||
mock_client = MagicMock()
|
||
mock_client.get_task_status.return_value = {"status": "running"}
|
||
|
||
class _Retry(Exception):
|
||
pass
|
||
|
||
fake_self = MagicMock()
|
||
fake_self.retry.side_effect = _Retry()
|
||
with (
|
||
self._patch_sessions(mock_db),
|
||
patch("app.services.mediakit_client.get_mediakit_client", return_value=mock_client),
|
||
patch("app.services.lipsync_service.LipsyncService"),
|
||
):
|
||
try:
|
||
self._run(mock_db, job_id="j5", fake_self=fake_self)
|
||
assert False, "expected retry"
|
||
except _Retry:
|
||
pass
|
||
assert mock_job.updated_at is not None
|
||
assert mock_job.status == "running"
|
||
assert fake_self.retry.call_count >= 1
|
||
|
||
|
||
class TestTtsSubmitDispatchesPoll:
|
||
"""#1867 三层防御 ③:tts_synthesize_and_submit 成功后链式触发 poll."""
|
||
|
||
def _run_tts(self, job, mk_client, fake_poll):
|
||
"""运行 tts_synthesize_and_submit.run,直接在用户函数 __globals__ 替换 poll_mediakit_status.
|
||
|
||
pytest --import-mode=importlib 下,patch("app.tasks.lipsync_tts.xxx") 拿到的模块对象
|
||
可能与 celery autoretry 包装下用户函数的 __globals__ 不一致(之前的 Poll 测试通过
|
||
patch.dict(sys.modules, ...) 注入 fake 模块,进一步加剧了模块命名空间不一致)。
|
||
直接 patch _orig_run.__func__.__globals__ 是最稳的方式。
|
||
"""
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
|
||
session, patches = _apply_all_patches(job=job, mk_client=mk_client)
|
||
for p in patches:
|
||
p.__enter__()
|
||
# 定位 autoretry 包装下的真实用户函数
|
||
real_tts = tts_synthesize_and_submit._get_current_object()
|
||
fn = real_tts._orig_run
|
||
while hasattr(fn, "__func__"):
|
||
fn = fn.__func__
|
||
g = fn.__globals__
|
||
orig_poll = g.get("poll_mediakit_status")
|
||
g["poll_mediakit_status"] = fake_poll
|
||
try:
|
||
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
|
||
finally:
|
||
if orig_poll is not None:
|
||
g["poll_mediakit_status"] = orig_poll
|
||
else:
|
||
g.pop("poll_mediakit_status", None)
|
||
for p in reversed(patches):
|
||
p.__exit__(None, None, None)
|
||
return session
|
||
|
||
def test_submit_success_dispatches_poll(self):
|
||
"""提交 MediaKit 成功后链式触发 poll_mediakit_status.apply_async (countdown=10)."""
|
||
job = _make_fake_job()
|
||
job.emotion = ""
|
||
mk_client = MagicMock()
|
||
mk_client.submit_lipsync.return_value = {"success": True, "task_id": "mk-poll-1"}
|
||
fake_poll = MagicMock()
|
||
self._run_tts(job, mk_client, fake_poll)
|
||
assert job.status == "submitted"
|
||
assert job.mediakit_task_id == "mk-poll-1"
|
||
fake_poll.apply_async.assert_called_once()
|
||
kwargs = fake_poll.apply_async.call_args.kwargs
|
||
assert kwargs["kwargs"] == {"job_id": "job-1", "user_id": "user-1"}
|
||
assert kwargs["countdown"] == 10
|
||
|
||
def test_submit_failure_no_poll(self):
|
||
from app.services.mediakit_client import MediaKitError
|
||
|
||
job = _make_fake_job()
|
||
job.emotion = ""
|
||
mk_client = MagicMock()
|
||
mk_client.submit_lipsync.side_effect = MediaKitError("bad", code="X")
|
||
fake_poll = MagicMock()
|
||
self._run_tts(job, mk_client, fake_poll)
|
||
assert job.status == "failed"
|
||
fake_poll.apply_async.assert_not_called()
|
||
|
||
def test_poll_dispatch_fail_does_not_break(self):
|
||
job = _make_fake_job()
|
||
job.emotion = ""
|
||
mk_client = MagicMock()
|
||
mk_client.submit_lipsync.return_value = {"success": True, "task_id": "mk-poll-2"}
|
||
fake_poll = MagicMock()
|
||
fake_poll.apply_async.side_effect = ConnectionError("celery down")
|
||
session = self._run_tts(job, mk_client, fake_poll)
|
||
assert job.status == "submitted"
|
||
assert job.mediakit_task_id == "mk-poll-2"
|
||
session.commit.assert_called()
|