796aeff499
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 166h12m36s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 166h12m41s
Deploy / Deploy Staging (push) Failing after 166h15m4s
CI/CD Pipeline / Frontend Lint (push) Failing after 166h15m32s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 166h15m39s
P2-1: workflow.py start_clone() 将 mark_processing() 移到音频 URL 检查之后,
无 URL 时保持 pending 状态,避免永久卡在 processing
P2-2: voice_clone.py Celery 任务改用 VoiceCloneWorkflowService 公开方法
(process_clone_result/process_clone_failure),不再直接操作 profile
P2-3: voice_clones.py 路由中 Celery .delay() 失败时调用
process_clone_failure() 标记 profile 为 failed(create + retry 两个路由)
P2-4: 由 P2-2 修复自动解决
同步更新 test_voice_clone_workflow.py 测试用例适配 P2-1 行为变更
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
8.9 KiB
Python
240 lines
8.9 KiB
Python
"""process_voice_clone Celery 任务单元测试。
|
||
|
||
关键:voice_clone.py 在模块级别 import worker_app.db.SessionLocal,
|
||
而 worker_app.db 会在导入时调用 ensure_database_exists() 尝试连接 PostgreSQL。
|
||
因此必须在 @patch 装饰器解析模块路径之前,将 worker_app.db 预注入 sys.modules。
|
||
|
||
Celery 5.x 中 @task(bind=True) 装饰后,task.run 是绑定方法(self 已绑定),
|
||
直接调用 task(profile_id) 即可,不需要手动传 self。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
# worker_app 在 apps/worker 下,需要加入 sys.path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||
|
||
# ── 预注入 mock 模块,防止 worker_app.db 触发真实数据库连接 ──
|
||
_mock_db_module = MagicMock()
|
||
_mock_db_module.SessionLocal = MagicMock()
|
||
sys.modules.setdefault("worker_app.db", _mock_db_module)
|
||
|
||
if "worker_app" in sys.modules:
|
||
sys.modules["worker_app"].db = _mock_db_module
|
||
|
||
from celery.exceptions import Retry
|
||
|
||
from packages.application.cosyvoice_service import (
|
||
CosyVoiceError,
|
||
CosyVoiceService,
|
||
CosyVoiceTimeoutError,
|
||
)
|
||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||
|
||
|
||
def _make_profile(
|
||
*,
|
||
status: VoiceCloneStatus = VoiceCloneStatus.PROCESSING,
|
||
metadata: dict | None = None,
|
||
) -> VoiceCloneProfile:
|
||
"""创建测试用 VoiceCloneProfile。"""
|
||
if metadata is None:
|
||
metadata = {"cosyvoice_task_id": "task-abc"}
|
||
profile = VoiceCloneProfile.create(
|
||
user_id="user-123",
|
||
name="测试音色",
|
||
source_audio_url="https://example.com/audio.wav",
|
||
max_retries=3,
|
||
metadata=metadata,
|
||
)
|
||
profile.status = status
|
||
return profile
|
||
|
||
|
||
# ── 成功场景 ──────────────────────────────────────────────
|
||
|
||
|
||
class TestProcessVoiceCloneSuccess:
|
||
"""测试成功场景。"""
|
||
|
||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||
def test_process_voice_clone_success(
|
||
self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock
|
||
) -> None:
|
||
"""克隆成功:轮询返回 voice_id,profile 标记为 ready。"""
|
||
mock_session = MagicMock()
|
||
mock_repo = MagicMock()
|
||
mock_service = MagicMock(spec=CosyVoiceService)
|
||
|
||
profile = _make_profile()
|
||
mock_repo.get.return_value = profile
|
||
mock_repo.update.side_effect = lambda p: p
|
||
mock_repo_cls.return_value = mock_repo
|
||
|
||
mock_service.poll_clone_task.return_value = {"voice_id": "voice-xyz"}
|
||
mock_service_cls.return_value = mock_service
|
||
|
||
_mock_db_module.SessionLocal.return_value = mock_session
|
||
|
||
from worker_app.tasks.voice_clone import process_voice_clone
|
||
|
||
# bind=True → run 是绑定方法,直接调用 task(profile_id)
|
||
result = process_voice_clone("profile-123")
|
||
|
||
assert result["ok"] is True
|
||
assert result["voice_id"] == "voice-xyz"
|
||
mock_service.poll_clone_task.assert_called_once_with("task-abc", timeout=300)
|
||
mock_session.commit.assert_called_once()
|
||
mock_session.close.assert_called_once()
|
||
|
||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||
def test_process_voice_clone_profile_not_found(
|
||
self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock
|
||
) -> None:
|
||
"""profile 不存在时返回 failed。"""
|
||
mock_session = MagicMock()
|
||
mock_repo = MagicMock()
|
||
mock_repo.get.return_value = None
|
||
mock_repo_cls.return_value = mock_repo
|
||
|
||
_mock_db_module.SessionLocal.return_value = mock_session
|
||
|
||
from worker_app.tasks.voice_clone import process_voice_clone
|
||
|
||
result = process_voice_clone("nonexistent")
|
||
|
||
assert result["ok"] is False
|
||
assert "not found" in result["error"].lower()
|
||
mock_session.close.assert_called_once()
|
||
|
||
|
||
# ── 超时场景 ──────────────────────────────────────────────
|
||
|
||
|
||
class TestProcessVoiceCloneTimeout:
|
||
"""测试超时场景。"""
|
||
|
||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||
def test_process_voice_clone_timeout_retries(
|
||
self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock
|
||
) -> None:
|
||
"""超时时调用 self.retry() 进行重试,Retry 异常向上传播。"""
|
||
mock_session = MagicMock()
|
||
mock_repo = MagicMock()
|
||
mock_service = MagicMock(spec=CosyVoiceService)
|
||
|
||
profile = _make_profile()
|
||
mock_repo.get.return_value = profile
|
||
mock_repo_cls.return_value = mock_repo
|
||
|
||
mock_service.poll_clone_task.side_effect = CosyVoiceTimeoutError("任务超时")
|
||
mock_service_cls.return_value = mock_service
|
||
|
||
_mock_db_module.SessionLocal.return_value = mock_session
|
||
|
||
from worker_app.tasks.voice_clone import process_voice_clone
|
||
|
||
# mock task.retry 使其抛出 Retry(模拟 Celery 行为)
|
||
with patch.object(process_voice_clone, "retry", side_effect=Retry("retrying")):
|
||
with pytest.raises(Retry):
|
||
process_voice_clone("profile-123")
|
||
|
||
mock_session.rollback.assert_called_once()
|
||
mock_session.close.assert_called_once()
|
||
|
||
|
||
# ── 失败场景 ──────────────────────────────────────────────
|
||
|
||
|
||
class TestProcessVoiceCloneFailure:
|
||
"""测试失败场景。"""
|
||
|
||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||
def test_process_voice_clone_cosyvoice_error(
|
||
self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock
|
||
) -> None:
|
||
"""CosyVoice 错误:profile 标记为 failed。"""
|
||
mock_session = MagicMock()
|
||
mock_repo = MagicMock()
|
||
mock_service = MagicMock(spec=CosyVoiceService)
|
||
|
||
profile = _make_profile()
|
||
mock_repo.get.return_value = profile
|
||
mock_repo.update.side_effect = lambda p: p
|
||
mock_repo_cls.return_value = mock_repo
|
||
|
||
mock_service.poll_clone_task.side_effect = CosyVoiceError("克隆失败")
|
||
mock_service_cls.return_value = mock_service
|
||
|
||
_mock_db_module.SessionLocal.return_value = mock_session
|
||
|
||
from worker_app.tasks.voice_clone import process_voice_clone
|
||
|
||
result = process_voice_clone("profile-123")
|
||
|
||
assert result["ok"] is False
|
||
assert "克隆失败" in result["error"]
|
||
mock_session.close.assert_called_once()
|
||
|
||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||
def test_process_voice_clone_unexpected_error(
|
||
self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock
|
||
) -> None:
|
||
"""意外异常:profile 标记为 failed。"""
|
||
mock_session = MagicMock()
|
||
mock_repo = MagicMock()
|
||
mock_service = MagicMock(spec=CosyVoiceService)
|
||
|
||
profile = _make_profile()
|
||
mock_repo.get.return_value = profile
|
||
mock_repo.update.side_effect = lambda p: p
|
||
mock_repo_cls.return_value = mock_repo
|
||
|
||
mock_service.poll_clone_task.side_effect = RuntimeError("未知错误")
|
||
mock_service_cls.return_value = mock_service
|
||
|
||
_mock_db_module.SessionLocal.return_value = mock_session
|
||
|
||
from worker_app.tasks.voice_clone import process_voice_clone
|
||
|
||
result = process_voice_clone("profile-123")
|
||
|
||
assert result["ok"] is False
|
||
assert "未知错误" in result["error"]
|
||
mock_session.close.assert_called_once()
|
||
|
||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||
def test_process_voice_clone_no_task_id(
|
||
self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock
|
||
) -> None:
|
||
"""metadata 中没有 cosyvoice_task_id 时返回 failed。"""
|
||
mock_session = MagicMock()
|
||
mock_repo = MagicMock()
|
||
|
||
# 显式传入空 dict,确保没有 cosyvoice_task_id
|
||
profile = _make_profile(metadata={})
|
||
mock_repo.get.return_value = profile
|
||
mock_repo.update.side_effect = lambda p: p
|
||
mock_repo_cls.return_value = mock_repo
|
||
|
||
_mock_db_module.SessionLocal.return_value = mock_session
|
||
|
||
from worker_app.tasks.voice_clone import process_voice_clone
|
||
|
||
result = process_voice_clone("profile-123")
|
||
|
||
assert result["ok"] is False
|
||
assert "task_id" in result["error"]
|
||
mock_session.close.assert_called_once()
|