Files
xiaoxia-saas/tests/unit/test_voice_clone_task.py
T
灵应 33876d10a2
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 / Staging E2E Tests (push) Failing after 35h37m27s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 35h40m18s
CI/CD Pipeline / Frontend Lint (push) Failing after 35h42m38s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 35h42m38s
fix: voice_clone task tests - patch SessionLocal for full suite compatibility
- Add @patch('worker_app.tasks.voice_clone.SessionLocal') to all 6 test methods
- Fixes test isolation issue where sys.modules.setdefault was no-op in full suite
- Ensures SessionLocal is mocked regardless of import order
2026-07-08 01:11:54 +08:00

322 lines
12 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),
Celery bind=True 任务的底层函数签名为 (self, profile_id)
CosyVoiceService 在 voice_clone.py 中被实例化传入 workflow,必须 mock 防止真实初始化。
跨环境兼容:
Python 3.13 + Celery 5.4.0 → import 返回 Celery Proxy
→ _get_current_object() 返回 Task 实例 → .run 是 bound methodself 已绑定)
→ 调用方式:task.run(profile_id)retry mock 在 task.run.retry
Python 3.10 + Celery 5.4.0 → import 返回原始函数(装饰器未生效)
→ 签名 (self, profile_id),需手动传 mock_self
→ 调用方式:func(mock_self, profile_id)retry mock 在 mock_self.retry
"""
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.application.voice_clone.use_cases import VoiceCloneNotFoundError
def _make_mock_profile(
*,
voice_id: str = "voice-xyz",
status: str = "ready",
) -> MagicMock:
"""创建测试用 mock profile。"""
profile = MagicMock()
profile.voice_id = voice_id
profile.status = status
return profile
def _resolve_task(task_obj):
"""解析 Celery 任务对象,返回 (callable, mock_self_or_none)。
跨环境兼容 Celery Proxy / Task 实例 / 原始函数三种情况。
Returns:
tuple: (callable, mock_self)
- Proxy/Task: callable 是 bound method task.runmock_self=None
- 原始函数: callable 是原始函数,mock_self 需由调用方提供
"""
# Case 1: Celery Proxy → 提取 Task 实例的 .runbound method
if hasattr(task_obj, "_get_current_object"):
real_task = task_obj._get_current_object()
return real_task.run, None
# Case 2: Celery Task 实例(非 Proxy
if hasattr(task_obj, "run") and hasattr(task_obj, "retry"):
return task_obj.run, None
# Case 3: 原始函数(CI 环境中装饰器未生效)
return task_obj, MagicMock()
# ── 成功场景 ──────────────────────────────────────────────
class TestProcessVoiceCloneSuccess:
"""测试成功场景。"""
@patch("worker_app.tasks.voice_clone.SessionLocal")
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
@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,
mock_cosy_cls: MagicMock,
mock_session_local: MagicMock,
) -> None:
"""克隆成功:轮询返回 voice_idprofile 标记为 ready。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_workflow_cls.return_value = mock_workflow
mock_result = _make_mock_profile(voice_id="voice-xyz")
mock_workflow.poll_and_process_clone.return_value = mock_result
mock_session_local.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
func, mock_self = _resolve_task(process_voice_clone)
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
result = func(*args)
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.SessionLocal")
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
@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,
mock_cosy_cls: MagicMock,
mock_session_local: MagicMock,
) -> None:
"""profile 不存在时 workflow 抛 VoiceCloneNotFoundError,返回 failed。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_workflow_cls.return_value = mock_workflow
mock_workflow.poll_and_process_clone.side_effect = VoiceCloneNotFoundError("Voice clone nonexistent not found")
mock_session_local.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
func, mock_self = _resolve_task(process_voice_clone)
args = (mock_self, "nonexistent") if mock_self else ("nonexistent",)
result = func(*args)
assert result["ok"] is False
assert "not found" in result["error"].lower()
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
# ── 超时场景 ──────────────────────────────────────────────
class TestProcessVoiceCloneTimeout:
"""测试超时场景。"""
@patch("worker_app.tasks.voice_clone.SessionLocal")
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
@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,
mock_cosy_cls: MagicMock,
mock_session_local: MagicMock,
) -> None:
"""超时时调用 self.retry() 进行重试,Retry 异常向上传播。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_workflow_cls.return_value = mock_workflow
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceTimeoutError("任务超时")
mock_session_local.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
func, mock_self = _resolve_task(process_voice_clone)
# 设置 retry mock:根据环境不同,retry 在不同对象上
if mock_self is None:
# Proxy/Task 环境:retry 在 Task 实例上(func 是 bound method task.run
real_task = process_voice_clone._get_current_object()
mock_retry = MagicMock()
mock_retry.side_effect = Retry("retrying")
with patch.object(real_task, "retry", mock_retry):
with pytest.raises(Retry):
func("profile-123")
mock_retry.assert_called_once()
else:
# 原始函数环境:retry 在 mock_self 上
mock_self.retry.side_effect = Retry("retrying")
with pytest.raises(Retry):
func(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.SessionLocal")
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
@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,
mock_cosy_cls: MagicMock,
mock_session_local: MagicMock,
) -> None:
"""CosyVoice 错误:profile 标记为 failed。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_workflow_cls.return_value = mock_workflow
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("克隆失败")
mock_session_local.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
func, mock_self = _resolve_task(process_voice_clone)
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
result = func(*args)
assert result["ok"] is False
assert "克隆失败" in result["error"]
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
@patch("worker_app.tasks.voice_clone.SessionLocal")
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
@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,
mock_cosy_cls: MagicMock,
mock_session_local: MagicMock,
) -> None:
"""意外异常:profile 标记为 failed。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_workflow_cls.return_value = mock_workflow
mock_workflow.poll_and_process_clone.side_effect = RuntimeError("未知错误")
mock_session_local.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
func, mock_self = _resolve_task(process_voice_clone)
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
result = func(*args)
assert result["ok"] is False
assert "未知错误" in result["error"]
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
@patch("worker_app.tasks.voice_clone.SessionLocal")
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
@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,
mock_cosy_cls: MagicMock,
mock_session_local: MagicMock,
) -> None:
"""metadata 中没有 cosyvoice_task_id 时返回 failed。"""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_workflow = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_workflow_cls.return_value = mock_workflow
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("missing task_id")
mock_session_local.return_value = mock_session
from worker_app.tasks.voice_clone import process_voice_clone
func, mock_self = _resolve_task(process_voice_clone)
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
result = func(*args)
assert result["ok"] is False
assert "task_id" in result["error"]
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()