Files
xiaoxia-saas/tests/unit/test_lipsync_gpu_async_task.py
T
xiaoxia f032152eaa
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 48s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m47s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m49s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 5m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m58s
CI/CD Pipeline / Unit Tests (push) Successful in 12m19s
CI/CD Pipeline / Validate - Security (push) Successful in 13m1s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 10m9s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(P0): 修复取消链路断裂,前端取消后 GPU 仍继续推理 (#2009)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-22 01:24:07 +08:00

289 lines
11 KiB
Python

"""Celery 任务 lipsync_gpu_process_async 直接单测 (#1978 异步化).
覆盖 apps/api/app/tasks/lipsync_gpu.py 的全部主路径:
- 成功:wait_for_result 返回 done → 签名 URL → completed
- GPU 超时/失败 → MediaKit 兜底(成功/MediaKitError/其他异常)
- job 不存在 / 状态异常提前返回
- 主流程异常 → job 标 failed
- _sign_media_url 各分支
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import app.tasks.lipsync_gpu as task_mod
import pytest
def _make_job(status="processing"):
job = MagicMock()
job.id = "job-1"
job.user_id = "u1"
job.status = status
job.video_url = "videos/v.mp4"
job.audio_url = "audios/a.wav"
job.enable_video_loop = True
return job
def _make_gpu_task(status="done", result_url="gpu-lipsync/results/t1.mp4", result_duration=11.2):
t = MagicMock()
t.status = status
t.result_url = result_url
t.result_duration = result_duration
return t
@pytest.fixture()
def db_patch():
"""patch _get_db_session 返回 MagicMock,并在任务结束后断言 close."""
fake_db = MagicMock()
with patch.object(task_mod, "_get_db_session", return_value=fake_db):
yield fake_db
def _patch_gpu_service(final_task):
fake_svc = MagicMock()
fake_svc.wait_for_result.return_value = final_task
return patch(
"app.services.gpu_lipsync_service.GpuLipsyncService",
return_value=fake_svc,
)
def _run_task():
# @shared_task bind=True:直接调用任务对象会自动注入 self
task_mod.lipsync_gpu_process_async("job-1", "u1", "gpu-task-1")
class TestHappyPath:
def test_gpu_done_marks_completed(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task()
storage = MagicMock()
storage.get_download_url.return_value = "https://signed.example.com/r1.mp4?sig=x"
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
):
_run_task()
assert job.status == "completed"
assert job.output_video_url == "https://signed.example.com/r1.mp4?sig=x"
assert job.output_duration == 11.2
assert job.completed_at is not None
db_patch.commit.assert_called_once()
db_patch.close.assert_called_once()
def test_gpu_done_empty_signed_url_keeps_original(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(result_url="gpu/r2.mp4")
storage = MagicMock()
storage.get_download_url.return_value = ""
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
):
_run_task()
assert job.status == "completed"
assert job.output_video_url == "gpu/r2.mp4"
def test_gpu_done_result_duration_none_defaults_zero(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(result_duration=None)
storage = MagicMock()
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
):
_run_task()
assert job.output_duration == 0.0
def test_sign_failure_uses_original_url(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(result_url="gpu/r3.mp4")
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", side_effect=RuntimeError("oss down")),
):
_run_task()
assert job.status == "completed"
assert job.output_video_url == "gpu/r3.mp4"
class TestJobGuards:
def test_job_not_found_returns(self, db_patch):
db_patch.query.return_value.filter_by.return_value.first.return_value = None
_run_task()
db_patch.commit.assert_not_called()
db_patch.close.assert_called_once()
def test_job_wrong_status_skipped(self, db_patch):
job = _make_job(status="completed")
db_patch.query.return_value.filter_by.return_value.first.return_value = job
_run_task()
db_patch.commit.assert_not_called()
class TestGpuFailureFallback:
def test_gpu_timeout_falls_back_mediakit_success(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
with (
_patch_gpu_service(None),
patch.object(task_mod, "_fallback_to_mediakit") as fb,
):
_run_task()
fb.assert_called_once_with(db_patch, job)
def test_gpu_failed_status_falls_back(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(status="failed")
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "_fallback_to_mediakit") as fb,
):
_run_task()
fb.assert_called_once_with(db_patch, job)
class TestFallbackToMediaKit:
def test_mediakit_success_marks_submitted(self, db_patch):
job = _make_job()
fake_client = MagicMock()
fake_client.submit_lipsync.return_value = {"task_id": "mk-99"}
with (
patch("app.services.mediakit_client.get_mediakit_client", return_value=fake_client),
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u + "?s"),
):
task_mod._fallback_to_mediakit(db_patch, job)
fake_client.submit_lipsync.assert_called_once()
kwargs = fake_client.submit_lipsync.call_args.kwargs
assert kwargs["enable_video_loop"] is True
assert kwargs["client_token"] == "job-1"
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-99"
db_patch.commit.assert_called_once()
def test_mediakit_error_marks_failed(self, db_patch):
from app.services.mediakit_client import MediaKitError
job = _make_job()
fake_client = MagicMock()
fake_client.submit_lipsync.side_effect = MediaKitError("api reject", code="MkReject")
with (
patch("app.services.mediakit_client.get_mediakit_client", return_value=fake_client),
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u),
):
task_mod._fallback_to_mediakit(db_patch, job)
assert job.status == "failed"
assert job.error_code == "MkReject"
db_patch.commit.assert_called_once()
def test_other_exception_marks_failed(self, db_patch):
job = _make_job()
with (
patch("app.services.mediakit_client.get_mediakit_client", side_effect=RuntimeError("boom")),
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u),
):
task_mod._fallback_to_mediakit(db_patch, job)
assert job.status == "failed"
assert job.error_code == "FallbackFailed"
db_patch.commit.assert_called_once()
class TestTaskException:
def test_unexpected_exception_marks_job_failed(self, db_patch):
job = _make_job()
# query 第一次返回 job,异常路径里再次 query 也返回 job
db_patch.query.return_value.filter_by.return_value.first.return_value = job
with patch(
"app.services.gpu_lipsync_service.GpuLipsyncService",
side_effect=RuntimeError("svc ctor fail"),
):
_run_task()
assert job.status == "failed"
assert job.error_code == "GpuAsyncError"
def test_exception_handler_failure_swallowed(self, db_patch):
# 主流程异常,且异常处理中的 query 也抛异常 → 不应再抛
db_patch.query.side_effect = RuntimeError("db totally broken")
_run_task()
db_patch.close.assert_called_once()
class TestSignMediaUrl:
def test_empty_url_returned_as_is(self):
assert task_mod._sign_media_url("") == ""
def test_non_own_host_returned_as_is(self):
storage = MagicMock()
storage.public_url = "https://own-bucket.oss-cn-beijing.aliyuncs.com"
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
url = "https://other.example.com/a.wav"
assert task_mod._sign_media_url(url) == url
def test_own_host_signed(self):
storage = MagicMock()
storage.public_url = "https://own-bucket.oss-cn-beijing.aliyuncs.com"
storage.get_download_url.return_value = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a?sig=1"
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
out = task_mod._sign_media_url("https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav")
assert out.endswith("?sig=1")
storage.get_download_url.assert_called_once()
def test_missing_public_url_returns_original(self):
storage = MagicMock()
storage.public_url = ""
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
url = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav"
assert task_mod._sign_media_url(url) == url
def test_exception_returns_original(self):
with patch.object(task_mod, "get_shared_storage_service", side_effect=RuntimeError("x")):
url = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav"
assert task_mod._sign_media_url(url) == url
def test_cancelled_gpu_task_does_not_fallback_mediakit(monkeypatch):
"""GPU 任务被用户取消 → Celery 任务直接标记 cancelled,不回退 MediaKit。"""
job = MagicMock()
job.id = "job-1"
job.status = "processing"
job.mediakit_task_id = "gpu:gpu-task-1"
gpu_task = MagicMock()
gpu_task.status = "cancelled"
gpu_task.error_msg = "用户取消"
fake_gpu_svc = MagicMock()
fake_gpu_svc.wait_for_result.return_value = gpu_task
gpu_service_cls = MagicMock(return_value=fake_gpu_svc)
fake_db = MagicMock()
fake_db.query.return_value.filter_by.return_value.first.return_value = job
# 直接替换 sys.modules 里的 gpu_lipsync_service 模块(全量跑时它可能已被
# 其他测试换成 MagicMock),保证任务函数内 from...import 一定拿到我们的类;
# 并替换 _get_db_session 绕开 worker_app / app.db 两条 import 分支。
import sys
from types import SimpleNamespace
fake_mod = SimpleNamespace(GpuLipsyncService=gpu_service_cls)
monkeypatch.setitem(sys.modules, "app.services.gpu_lipsync_service", fake_mod)
monkeypatch.setattr(task_mod, "_get_db_session", lambda: fake_db)
monkeypatch.setattr(task_mod, "logger", MagicMock())
task_mod.lipsync_gpu_process_async.run("job-1", "u1", "gpu-task-1")
assert job.status == "cancelled"
assert not str(job.mediakit_task_id).startswith("mk-")
fake_db.commit.assert_called()
fake_gpu_svc.wait_for_result.assert_called_once()
gpu_service_cls.assert_called_once_with(fake_db)