"""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()