Files
xiaoxia-saas/tests/unit/test_voice_clone_task.py
T
灵应 8935196fcd
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 138h4m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 138h4m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h4m39s
style: 后端代码black格式化
2026-07-03 18:49:54 +08:00

228 lines
8.8 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。
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_idprofile 标记为 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()