Files
xiaoxia-saas/tests/unit/test_voice_clone_task.py
T
灵应 7ef4b0677a
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (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 / Frontend Lint (push) Failing after 36h19m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 36h19m6s
style: black format test_voice_clone_task.py
2026-07-08 00:35:21 +08:00

250 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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。
注意:production code 使用 VoiceCloneWorkflowService(非直接 CosyVoiceService),
且 @task(bind=True) 装饰后直接 import 的函数需要显式传入 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,
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.VoiceCloneWorkflowService")
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
def test_process_voice_clone_success(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
"""克隆成功:轮询返回 voice_idprofile 标记为 ready。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
profile = _make_profile()
mock_repo.get.return_value = profile
mock_repo_cls.return_value = mock_repo
mock_workflow.poll_and_process_clone.return_value = VoiceCloneProfile(
id="profile-123",
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
max_retries=3,
metadata={"cosyvoice_task_id": "task-abc"},
voice_id="voice-xyz",
status=VoiceCloneStatus.READY,
)
mock_workflow_cls.return_value = mock_workflow
_mock_db_module.SessionLocal.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
# bind=True: 直接调用需传入 self 参数
mock_self = MagicMock()
result = process_voice_clone(mock_self, "profile-123")
assert result["ok"] is True
assert result["voice_id"] == "voice-xyz"
mock_workflow.poll_and_process_clone.assert_called_once_with("profile-123", timeout=300)
mock_session.commit.assert_called_once()
mock_session.close.assert_called_once()
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
def test_process_voice_clone_profile_not_found(
self, mock_repo_cls: MagicMock, mock_workflow_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
mock_self = MagicMock()
result = process_voice_clone(mock_self, "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.VoiceCloneWorkflowService")
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
def test_process_voice_clone_timeout_retries(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
"""超时时调用 self.retry() 进行重试,Retry 异常向上传播。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
profile = _make_profile()
mock_repo.get.return_value = profile
mock_repo_cls.return_value = mock_repo
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceTimeoutError("任务超时")
mock_workflow_cls.return_value = mock_workflow
_mock_db_module.SessionLocal.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
# bind=True: mock self,设置 retry 抛出 Retry
mock_self = MagicMock()
mock_self.retry.side_effect = Retry("retrying")
with pytest.raises(Retry):
process_voice_clone(mock_self, "profile-123")
mock_self.retry.assert_called_once()
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
# ── 失败场景 ──────────────────────────────────────────────
class TestProcessVoiceCloneFailure:
"""测试失败场景。"""
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
def test_process_voice_clone_cosyvoice_error(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
"""CosyVoice 错误:profile 标记为 failed。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
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_workflow.poll_and_process_clone.side_effect = CosyVoiceError("克隆失败")
mock_workflow_cls.return_value = mock_workflow
_mock_db_module.SessionLocal.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
mock_self = MagicMock()
result = process_voice_clone(mock_self, "profile-123")
assert result["ok"] is False
assert "克隆失败" in result["error"]
mock_session.close.assert_called_once()
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
def test_process_voice_clone_unexpected_error(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
"""意外异常:profile 标记为 failed。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
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_workflow.poll_and_process_clone.side_effect = RuntimeError("未知错误")
mock_workflow_cls.return_value = mock_workflow
_mock_db_module.SessionLocal.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
mock_self = MagicMock()
result = process_voice_clone(mock_self, "profile-123")
assert result["ok"] is False
assert "未知错误" in result["error"]
mock_session.close.assert_called_once()
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
def test_process_voice_clone_no_task_id(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
"""metadata 中没有 cosyvoice_task_id 时返回 failed。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = 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
# workflow 对缺少 task_id 的 profile 抛出 CosyVoiceError
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("missing task_id")
mock_workflow_cls.return_value = mock_workflow
_mock_db_module.SessionLocal.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
mock_self = MagicMock()
result = process_voice_clone(mock_self, "profile-123")
assert result["ok"] is False
assert "task_id" in result["error"]
mock_session.close.assert_called_once()