"""VoiceCloneWorkflowService 单元测试。""" from __future__ import annotations from unittest.mock import MagicMock import pytest from packages.application.cosyvoice_service import ( CosyVoiceAuthError, CosyVoiceError, CosyVoiceService, ) from packages.application.voice_clone.use_cases import ( VoiceCloneNotFoundError, VoiceCloneNotRetryableError, ) from packages.application.voice_clone.workflow import VoiceCloneWorkflowService from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus def _make_profile( *, status: VoiceCloneStatus = VoiceCloneStatus.PENDING, source_audio_url: str = "https://example.com/audio.wav", retry_count: int = 0, max_retries: int = 3, metadata: dict | None = None, ) -> VoiceCloneProfile: """创建测试用 VoiceCloneProfile。""" profile = VoiceCloneProfile.create( user_id="user-123", name="测试音色", source_audio_url=source_audio_url, max_retries=max_retries, metadata=metadata, ) profile.status = status profile.retry_count = retry_count return profile def _make_service( *, repo: MagicMock | None = None, cosyvoice: MagicMock | None = None, ) -> VoiceCloneWorkflowService: """创建测试用 VoiceCloneWorkflowService。""" mock_repo = repo or MagicMock() mock_cosyvoice = cosyvoice or MagicMock(spec=CosyVoiceService) return VoiceCloneWorkflowService(repository=mock_repo, cosyvoice_service=mock_cosyvoice) # ── start_clone ────────────────────────────────────────── class TestStartClone: """测试 start_clone 方法。""" def test_start_clone_with_deploying(self) -> None: """提交克隆后返回 DEPLOYING 状态,profile 保持 processing。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) # CosyVoice 返回 voice_id + DEPLOYING 状态(需轮询) mock_cosyvoice.submit_clone_task.return_value = { "voice_id": "voice-abc", "status": "DEPLOYING", "request_id": "req-123", } # repo.create 和 repo.update 返回传入的 profile mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="https://example.com/audio.wav", ) assert profile.status == VoiceCloneStatus.PROCESSING assert profile.metadata["cosyvoice_task_id"] == "voice-abc" assert profile.metadata["cosyvoice_request_id"] == "req-123" mock_cosyvoice.submit_clone_task.assert_called_once() assert mock_repo.create.call_count == 1 # update 至少调用 2 次:mark_processing + 保存 voice_id assert mock_repo.update.call_count >= 2 def test_start_clone_with_ok_status(self) -> None: """CosyVoice 直接返回 OK 状态,profile 变为 ready。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) mock_cosyvoice.submit_clone_task.return_value = { "voice_id": "voice-sync-123", "status": "OK", "request_id": "req-456", } mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="https://example.com/audio.wav", ) assert profile.status == VoiceCloneStatus.READY assert profile.voice_id == "voice-sync-123" def test_start_clone_cosyvoice_error(self) -> None: """CosyVoice 提交失败,profile 标记为 failed。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("API 调用失败") mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="https://example.com/audio.wav", ) assert profile.status == VoiceCloneStatus.FAILED assert "API 调用失败" in profile.error_message def test_start_clone_auth_error(self) -> None: """CosyVoice 认证失败,profile 标记为 failed。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceAuthError("认证失败") mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="https://example.com/audio.wav", ) assert profile.status == VoiceCloneStatus.FAILED assert "认证失败" in profile.error_message def test_start_clone_without_audio_url(self) -> None: """没有音频 URL 时,profile 保持 pending 状态(P2-1 修复后)。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="", ) # P2-1: 没有音频 URL 时不标记 processing,保持 pending assert profile.status == VoiceCloneStatus.PENDING mock_cosyvoice.submit_clone_task.assert_not_called() def test_start_clone_ssrf_internal_url_rejected(self) -> None: """SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="http://127.0.0.1/audio.wav", ) # 内网 IP 应该被拒绝,标记为 failed assert profile.status == VoiceCloneStatus.FAILED assert "安全校验失败" in profile.error_message mock_cosyvoice.submit_clone_task.assert_not_called() def test_start_clone_ssrf_private_ip_rejected(self) -> None: """SSRF 防护:私有网段 IP 应该被拒绝,profile 标记为 failed。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="http://192.168.1.100/audio.wav", ) assert profile.status == VoiceCloneStatus.FAILED assert "安全校验失败" in profile.error_message mock_cosyvoice.submit_clone_task.assert_not_called() def test_start_clone_ssrf_public_url_passes(self) -> None: """SSRF 防护:正常公网 URL 应该通过校验。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) mock_cosyvoice.submit_clone_task.return_value = { "voice_id": "voice-ssrf-test", "status": "DEPLOYING", "request_id": "req-ssrf", } mock_repo.create.side_effect = lambda p: p mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) profile = service.start_clone( user_id="user-123", name="测试音色", source_audio_url="https://example.com/audio.wav", ) # 公网 URL 应该正常通过 assert profile.status == VoiceCloneStatus.PROCESSING mock_cosyvoice.submit_clone_task.assert_called_once() # ── process_clone_result ───────────────────────────────── class TestProcessCloneResult: """测试 process_clone_result 方法。""" def test_process_clone_result_success(self) -> None: """克隆成功,profile 标记为 ready。""" mock_repo = MagicMock() profile = _make_profile(status=VoiceCloneStatus.PROCESSING) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo) result = service.process_clone_result(profile.id, "voice-xyz") assert result.status == VoiceCloneStatus.READY assert result.voice_id == "voice-xyz" def test_process_clone_result_not_found(self) -> None: """profile 不存在时抛出异常。""" mock_repo = MagicMock() mock_repo.get.return_value = None service = _make_service(repo=mock_repo) with pytest.raises(VoiceCloneNotFoundError): service.process_clone_result("nonexistent", "voice-xyz") # ── process_clone_failure ──────────────────────────────── class TestProcessCloneFailure: """测试 process_clone_failure 方法。""" def test_process_clone_failure(self) -> None: """克隆失败,profile 标记为 failed。""" mock_repo = MagicMock() profile = _make_profile(status=VoiceCloneStatus.PROCESSING) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo) result = service.process_clone_failure(profile.id, "超时错误") assert result.status == VoiceCloneStatus.FAILED assert result.error_message == "超时错误" def test_process_clone_failure_not_found(self) -> None: """profile 不存在时抛出异常。""" mock_repo = MagicMock() mock_repo.get.return_value = None service = _make_service(repo=mock_repo) with pytest.raises(VoiceCloneNotFoundError): service.process_clone_failure("nonexistent", "错误") # ── retry_clone ────────────────────────────────────────── class TestRetryClone: """测试 retry_clone 方法。""" def test_retry_clone_with_async_task(self) -> None: """重试成功,异步模式。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=1, max_retries=3) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p mock_cosyvoice.submit_clone_task.return_value = { "voice_id": "voice-retry", "status": "DEPLOYING", "request_id": "req-retry", } service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) result = service.retry_clone(profile.id, "user-123") assert result.status == VoiceCloneStatus.PROCESSING assert result.metadata["cosyvoice_task_id"] == "voice-retry" assert result.retry_count == 2 # prepare_retry 增加了一次 def test_retry_clone_with_ok_status(self) -> None: """重试成功,直接返回 OK 状态。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p mock_cosyvoice.submit_clone_task.return_value = { "voice_id": "voice-retry-sync", "status": "OK", "request_id": "req-retry", } service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) result = service.retry_clone(profile.id, "user-123") assert result.status == VoiceCloneStatus.READY assert result.voice_id == "voice-retry-sync" def test_retry_clone_not_found(self) -> None: """profile 不存在时抛出异常。""" mock_repo = MagicMock() mock_repo.get.return_value = None service = _make_service(repo=mock_repo) with pytest.raises(VoiceCloneNotFoundError): service.retry_clone("nonexistent", "user-123") def test_retry_clone_not_retryable(self) -> None: """不可重试时抛出异常。""" mock_repo = MagicMock() profile = _make_profile(status=VoiceCloneStatus.PROCESSING) mock_repo.get.return_value = profile service = _make_service(repo=mock_repo) with pytest.raises(VoiceCloneNotRetryableError): service.retry_clone(profile.id, "user-123") def test_retry_clone_cosyvoice_error(self) -> None: """重试时 CosyVoice 失败,profile 标记为 failed。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("重试失败") service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) result = service.retry_clone(profile.id, "user-123") assert result.status == VoiceCloneStatus.FAILED assert "重试失败" in result.error_message def test_retry_clone_ssrf_internal_url_rejected(self) -> None: """重试时 SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) profile = _make_profile( status=VoiceCloneStatus.FAILED, source_audio_url="http://10.0.0.1/secret.wav", retry_count=0, max_retries=3, ) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) result = service.retry_clone(profile.id, "user-123") assert result.status == VoiceCloneStatus.FAILED assert "安全校验失败" in result.error_message mock_cosyvoice.submit_clone_task.assert_not_called() # ── poll_and_process_clone ─────────────────────────────── class TestPollAndProcessClone: """测试 poll_and_process_clone 方法(P2-2 修复)。""" def test_poll_and_process_clone_success(self) -> None: """轮询成功:调用 poll_clone_task → process_clone_result。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) profile = _make_profile( status=VoiceCloneStatus.PROCESSING, metadata={"cosyvoice_task_id": "task-abc"}, ) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p mock_cosyvoice.poll_clone_task.return_value = {"voice_id": "voice-poll-xyz"} service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) result = service.poll_and_process_clone(profile.id) assert result.status == VoiceCloneStatus.READY assert result.voice_id == "voice-poll-xyz" mock_cosyvoice.poll_clone_task.assert_called_once_with("task-abc", timeout=300) def test_poll_and_process_clone_no_task_id(self) -> None: """metadata 中没有 task_id 时抛出 ValueError。""" mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) profile = _make_profile(status=VoiceCloneStatus.PROCESSING, metadata={}) mock_repo.get.return_value = profile service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) with pytest.raises(ValueError, match="cosyvoice_task_id"): service.poll_and_process_clone(profile.id) def test_poll_and_process_clone_not_found(self) -> None: """profile 不存在时抛出 VoiceCloneNotFoundError。""" mock_repo = MagicMock() mock_repo.get.return_value = None service = _make_service(repo=mock_repo) with pytest.raises(VoiceCloneNotFoundError): service.poll_and_process_clone("nonexistent") def test_poll_and_process_clone_timeout(self) -> None: """超时时透传 CosyVoiceTimeoutError(由 Celery task 捕获重试)。""" from packages.application.cosyvoice_service import CosyVoiceTimeoutError mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) profile = _make_profile( status=VoiceCloneStatus.PROCESSING, metadata={"cosyvoice_task_id": "task-abc"}, ) mock_repo.get.return_value = profile mock_cosyvoice.poll_clone_task.side_effect = CosyVoiceTimeoutError("超时") service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) with pytest.raises(CosyVoiceTimeoutError): service.poll_and_process_clone(profile.id)