From 0fdd12755c7387d09a0e6e0780b31b4b88fea8ec Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 22 Jul 2026 18:43:32 +0800 Subject: [PATCH 1/2] test(p3-1): add voice_clone_workflow unit tests (22 cases) Cover start_clone (sync/async path, error handling, metadata), poll_and_process, process_result, process_failure, retry_clone, and workflow error classes. --- tests/unit/test_voice_clone_workflow.py | 807 ++++++++++++------------ 1 file changed, 392 insertions(+), 415 deletions(-) diff --git a/tests/unit/test_voice_clone_workflow.py b/tests/unit/test_voice_clone_workflow.py index 7820e1adc..64d67880c 100755 --- a/tests/unit/test_voice_clone_workflow.py +++ b/tests/unit/test_voice_clone_workflow.py @@ -1,466 +1,443 @@ -"""VoiceCloneWorkflowService 单元测试。""" +"""Voice clone workflow service unit tests. + +Covers VoiceCloneWorkflowService - start_clone, poll_and_process_clone, +process_clone_result, process_clone_failure, retry_clone. +""" from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest -from packages.application.cosyvoice_service import ( - CosyVoiceAuthError, - CosyVoiceError, - CosyVoiceService, +from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError +from packages.application.voice_clone.workflow import ( + VoiceCloneWorkflowError, + VoiceCloneWorkflowService, ) -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 +from packages.shared.url_security import UrlSecurityError -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, +# ── Helpers ───────────────────────────────────────────── + + +def make_profile(**kwargs): + defaults = dict( + id="profile-123", + user_id="user-1", + name="我的音色", + description="", + source_audio_url="", + voice_model="", + language="zh-CN", + gender="unknown", + max_retries=3, ) - profile.status = status - profile.retry_count = retry_count - return profile + defaults.update(kwargs) + return VoiceCloneProfile(**defaults) -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) +class FakeProfileRepository: + def __init__(self, profile=None): + self._profile = profile + self.updated = [] + self.created = [] + self.get_called = 0 + + def create(self, profile): + self.created.append(profile) + self._profile = profile + return profile + + def get(self, profile_id): + self.get_called += 1 + if self._profile and self._profile.id == profile_id: + return self._profile + return None + + def update(self, profile): + self.updated.append(profile) + self._profile = profile + return profile + + def find_by_user(self, user_id): + if self._profile and self._profile.user_id == user_id: + return [self._profile] + return [] -# ── start_clone ────────────────────────────────────────── +class FakeCosyVoiceService: + def __init__(self, submit_result=None, submit_error=None, poll_result=None, poll_error=None): + self._submit_result = submit_result or { + "voice_id": "voice_abc123", + "status": "DEPLOYING", + "request_id": "req-abc", + } + self._submit_error = submit_error + self._poll_result = poll_result or {"voice_id": "voice_abc123"} + self._poll_error = poll_error + self.submit_calls = [] + self.poll_calls = [] + + def submit_clone_task(self, **kwargs): + self.submit_calls.append(kwargs) + if self._submit_error: + raise self._submit_error + return self._submit_result + + def poll_clone_task(self, voice_id, timeout=300.0): + self.poll_calls.append({"voice_id": voice_id, "timeout": timeout}) + if self._poll_error: + raise self._poll_error + return self._poll_result + + +# ── start_clone tests ─────────────────────────────────── 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", + def test_with_audio_url_submits_and_returns_processing(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService(submit_result={ + "voice_id": "voice_new123", "status": "DEPLOYING", - "request_id": "req-123", - } + "request_id": "req-xyz", + }) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - # repo.create 和 repo.update 返回传入的 profile - mock_repo.create.side_effect = lambda p: p - mock_repo.update.side_effect = lambda p: p + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/audio.mp3"): + result = svc.start_clone( + user_id="user-1", + name="测试音色", + source_audio_url="https://example.com/audio.mp3", + ) - 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 result.user_id == "user-1" + assert result.name == "测试音色" + assert result.status == VoiceCloneStatus.PROCESSING.value + assert result.metadata["cosyvoice_task_id"] == "voice_new123" + assert result.metadata["cosyvoice_request_id"] == "req-xyz" - 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 + # CosyVoice was called + assert len(cosy.submit_calls) == 1 + assert cosy.submit_calls[0]["audio_url"] == "https://safe.example.com/audio.mp3" + assert cosy.submit_calls[0]["voice_name"] == "测试音色" - def test_start_clone_with_ok_status(self) -> None: - """CosyVoice 直接返回 OK 状态,profile 变为 ready。""" - mock_repo = MagicMock() - mock_cosyvoice = MagicMock(spec=CosyVoiceService) + def test_without_audio_url_returns_pending(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - 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="测试音色", + result = svc.start_clone( + user_id="user-1", + name="待上传音色", source_audio_url="", ) - # P2-1: 没有音频 URL 时不标记 processing,保持 pending - assert profile.status == VoiceCloneStatus.PENDING - mock_cosyvoice.submit_clone_task.assert_not_called() + assert result.status == VoiceCloneStatus.PENDING.value + # No CosyVoice call + assert len(cosy.submit_calls) == 0 - 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", + def test_sync_completion_status_ok(self): + """CosyVoice returns OK status directly → mark ready.""" + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService(submit_result={ + "voice_id": "voice_ready", "status": "OK", - "request_id": "req-retry", - } + "request_id": "req-ok", + }) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) - result = service.retry_clone(profile.id, "user-123") + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + result = svc.start_clone( + user_id="user-1", + name="秒成音色", + source_audio_url="https://example.com/a.mp3", + ) - assert result.status == VoiceCloneStatus.READY - assert result.voice_id == "voice-retry-sync" + assert result.status == VoiceCloneStatus.READY.value + assert result.voice_id == "voice_ready" - def test_retry_clone_not_found(self) -> None: - """profile 不存在时抛出异常。""" - mock_repo = MagicMock() - mock_repo.get.return_value = None + def test_url_security_failure_marks_failed(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - service = _make_service(repo=mock_repo) - with pytest.raises(VoiceCloneNotFoundError): - service.retry_clone("nonexistent", "user-123") + with patch("packages.application.voice_clone.workflow.validate_url_safety", + side_effect=UrlSecurityError("SSRF detected: internal IP")): + result = svc.start_clone( + user_id="user-1", + name="危险音色", + source_audio_url="https://10.0.0.1/internal.mp3", + ) - 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 result.status == VoiceCloneStatus.FAILED.value assert "安全校验失败" in result.error_message - mock_cosyvoice.submit_clone_task.assert_not_called() + # No CosyVoice call + assert len(cosy.submit_calls) == 0 + + def test_cosyvoice_error_marks_failed(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("API rate limit exceeded")) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + result = svc.start_clone( + user_id="user-1", + name="失败音色", + source_audio_url="https://example.com/a.mp3", + ) + + assert result.status == VoiceCloneStatus.FAILED.value + assert "API rate limit" in result.error_message + + def test_cosyvoice_auth_error_marks_failed(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService(submit_error=CosyVoiceAuthError("Invalid API key")) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + result = svc.start_clone( + user_id="user-1", + name="鉴权失败", + source_audio_url="https://example.com/a.mp3", + ) + + assert result.status == VoiceCloneStatus.FAILED.value + assert "Invalid API key" in result.error_message + + def test_value_error_marks_failed(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService(submit_error=ValueError("audio_url is empty")) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + result = svc.start_clone( + user_id="user-1", + name="参数错误", + source_audio_url="https://example.com/a.mp3", + ) + + assert result.status == VoiceCloneStatus.FAILED.value + assert "audio_url is empty" in result.error_message + + def test_custom_language_and_model(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"): + result = svc.start_clone( + user_id="user-1", + name="English Voice", + source_audio_url="https://example.com/a.mp3", + language="en-US", + voice_model="cosyvoice-v3", + ) + + assert cosy.submit_calls[0]["language"] == "en-US" + assert result.language == "en-US" + assert result.voice_model == "cosyvoice-v3" + + def test_metadata_passed_through(self): + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"): + result = svc.start_clone( + user_id="user-1", + name="带元数据", + source_audio_url="https://example.com/a.mp3", + metadata={"source": "upload", "format": "wav"}, + ) + + assert result.metadata.get("source") == "upload" + assert result.metadata.get("format") == "wav" + # cosyvoice keys also present + assert "cosyvoice_task_id" in result.metadata -# ── poll_and_process_clone ─────────────────────────────── +# ── poll_and_process_clone tests ──────────────────────── class TestPollAndProcessClone: - """测试 poll_and_process_clone 方法(P2-2 修复)。""" + def test_poll_success_returns_ready(self): + profile = make_profile() + profile.mark_processing() + profile.metadata = {"cosyvoice_task_id": "voice_pending"} + repo = FakeProfileRepository(profile=profile) + cosy = FakeCosyVoiceService(poll_result={"voice_id": "voice_done"}) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - def test_poll_and_process_clone_success(self) -> None: - """轮询成功:调用 poll_clone_task → process_clone_result。""" - mock_repo = MagicMock() - mock_cosyvoice = MagicMock(spec=CosyVoiceService) + result = svc.poll_and_process_clone("profile-123", timeout=60.0) - 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 + assert result.status == VoiceCloneStatus.READY.value + assert result.voice_id == "voice_done" + assert len(cosy.poll_calls) == 1 + assert cosy.poll_calls[0]["voice_id"] == "voice_pending" + assert cosy.poll_calls[0]["timeout"] == 60.0 - mock_cosyvoice.poll_clone_task.return_value = {"voice_id": "voice-poll-xyz"} + def test_profile_not_found_raises(self): + from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError - service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice) - result = service.poll_and_process_clone(profile.id) + repo = FakeProfileRepository() + cosy = FakeCosyVoiceService() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - 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") + svc.poll_and_process_clone("nonexistent") - def test_poll_and_process_clone_timeout(self) -> None: - """超时时透传 CosyVoiceTimeoutError(由 Celery task 捕获重试)。""" + def test_no_task_id_raises_value_error(self): + profile = make_profile() + profile.mark_processing() + profile.metadata = {} # no task_id + repo = FakeProfileRepository(profile=profile) + cosy = FakeCosyVoiceService() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with pytest.raises(ValueError, match="no cosyvoice_task_id"): + svc.poll_and_process_clone("profile-123") + + def test_poll_timeout_propagates(self): from packages.application.cosyvoice_service import CosyVoiceTimeoutError - mock_repo = MagicMock() - mock_cosyvoice = MagicMock(spec=CosyVoiceService) + profile = make_profile() + profile.mark_processing() + profile.metadata = {"cosyvoice_task_id": "voice_slow"} + repo = FakeProfileRepository(profile=profile) + cosy = FakeCosyVoiceService(poll_error=CosyVoiceTimeoutError("timed out")) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - 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) + svc.poll_and_process_clone("profile-123") + + +# ── process_clone_result tests ────────────────────────── + + +class TestProcessCloneResult: + def test_marks_profile_ready(self): + profile = make_profile() + profile.mark_processing() + repo = FakeProfileRepository(profile=profile) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService()) + + result = svc.process_clone_result("profile-123", "voice_final123") + + assert result.status == VoiceCloneStatus.READY.value + assert result.voice_id == "voice_final123" + + def test_profile_not_found_raises(self): + from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError + + repo = FakeProfileRepository() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService()) + + with pytest.raises(VoiceCloneNotFoundError): + svc.process_clone_result("nonexistent", "voice_x") + + +# ── process_clone_failure tests ───────────────────────── + + +class TestProcessCloneFailure: + def test_marks_profile_failed(self): + profile = make_profile() + profile.mark_processing() + repo = FakeProfileRepository(profile=profile) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService()) + + result = svc.process_clone_failure("profile-123", "审核未通过") + + assert result.status == VoiceCloneStatus.FAILED.value + assert "审核未通过" in result.error_message + + def test_profile_not_found_raises(self): + from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError + + repo = FakeProfileRepository() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService()) + + with pytest.raises(VoiceCloneNotFoundError): + svc.process_clone_failure("nonexistent", "error") + + +# ── retry_clone tests ─────────────────────────────────── + + +class TestRetryClone: + def test_retry_success_submits_again(self): + profile = make_profile(source_audio_url="https://example.com/orig.mp3") + profile.mark_failed("previous error") + repo = FakeProfileRepository(profile=profile) + cosy = FakeCosyVoiceService(submit_result={ + "voice_id": "voice_retry", + "status": "DEPLOYING", + "request_id": "req-retry", + }) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/orig.mp3"): + result = svc.retry_clone("profile-123", "user-1") + + # Should be processing again + assert result.status == VoiceCloneStatus.PROCESSING.value + assert result.metadata["cosyvoice_task_id"] == "voice_retry" + assert len(cosy.submit_calls) == 1 + + def test_retry_url_security_failure(self): + profile = make_profile(source_audio_url="https://10.0.0.1/audio.mp3") + profile.mark_failed("old error") + repo = FakeProfileRepository(profile=profile) + cosy = FakeCosyVoiceService() + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", + side_effect=UrlSecurityError("internal IP")): + result = svc.retry_clone("profile-123", "user-1") + + assert result.status == VoiceCloneStatus.FAILED.value + assert "安全校验失败" in result.error_message + assert len(cosy.submit_calls) == 0 + + def test_retry_cosyvoice_error(self): + profile = make_profile(source_audio_url="https://example.com/a.mp3") + profile.mark_failed("old error") + repo = FakeProfileRepository(profile=profile) + cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("still failing")) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + result = svc.retry_clone("profile-123", "user-1") + + assert result.status == VoiceCloneStatus.FAILED.value + assert "still failing" in result.error_message + + def test_retry_sync_ok(self): + profile = make_profile(source_audio_url="https://example.com/a.mp3") + profile.mark_failed("old error") + repo = FakeProfileRepository(profile=profile) + cosy = FakeCosyVoiceService(submit_result={ + "voice_id": "voice_instant", + "status": "OK", + "request_id": "req-instant", + }) + svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) + + with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + result = svc.retry_clone("profile-123", "user-1") + + assert result.status == VoiceCloneStatus.READY.value + assert result.voice_id == "voice_instant" + + +# ── Error class tests ─────────────────────────────────── + + +class TestErrorClasses: + def test_workflow_error_inherits_exception(self): + err = VoiceCloneWorkflowError("test error") + assert isinstance(err, Exception) + assert str(err) == "test error" -- 2.54.0 From 9c58820411ee8e3740c2e5ae528b3046a15e9fcd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 22:31:51 +0800 Subject: [PATCH 2/2] style: black + isort format fix --- tests/unit/test_voice_clone_workflow.py | 108 ++++++++++++++++-------- 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/tests/unit/test_voice_clone_workflow.py b/tests/unit/test_voice_clone_workflow.py index 64d67880c..9adf2f056 100755 --- a/tests/unit/test_voice_clone_workflow.py +++ b/tests/unit/test_voice_clone_workflow.py @@ -18,7 +18,6 @@ from packages.application.voice_clone.workflow import ( from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus from packages.shared.url_security import UrlSecurityError - # ── Helpers ───────────────────────────────────────────── @@ -99,14 +98,19 @@ class FakeCosyVoiceService: class TestStartClone: def test_with_audio_url_submits_and_returns_processing(self): repo = FakeProfileRepository() - cosy = FakeCosyVoiceService(submit_result={ - "voice_id": "voice_new123", - "status": "DEPLOYING", - "request_id": "req-xyz", - }) + cosy = FakeCosyVoiceService( + submit_result={ + "voice_id": "voice_new123", + "status": "DEPLOYING", + "request_id": "req-xyz", + } + ) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/audio.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/audio.mp3", + ): result = svc.start_clone( user_id="user-1", name="测试音色", @@ -142,14 +146,19 @@ class TestStartClone: def test_sync_completion_status_ok(self): """CosyVoice returns OK status directly → mark ready.""" repo = FakeProfileRepository() - cosy = FakeCosyVoiceService(submit_result={ - "voice_id": "voice_ready", - "status": "OK", - "request_id": "req-ok", - }) + cosy = FakeCosyVoiceService( + submit_result={ + "voice_id": "voice_ready", + "status": "OK", + "request_id": "req-ok", + } + ) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/a.mp3", + ): result = svc.start_clone( user_id="user-1", name="秒成音色", @@ -164,8 +173,10 @@ class TestStartClone: cosy = FakeCosyVoiceService() svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", - side_effect=UrlSecurityError("SSRF detected: internal IP")): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + side_effect=UrlSecurityError("SSRF detected: internal IP"), + ): result = svc.start_clone( user_id="user-1", name="危险音色", @@ -182,7 +193,10 @@ class TestStartClone: cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("API rate limit exceeded")) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/a.mp3", + ): result = svc.start_clone( user_id="user-1", name="失败音色", @@ -197,7 +211,10 @@ class TestStartClone: cosy = FakeCosyVoiceService(submit_error=CosyVoiceAuthError("Invalid API key")) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/a.mp3", + ): result = svc.start_clone( user_id="user-1", name="鉴权失败", @@ -212,7 +229,10 @@ class TestStartClone: cosy = FakeCosyVoiceService(submit_error=ValueError("audio_url is empty")) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/a.mp3", + ): result = svc.start_clone( user_id="user-1", name="参数错误", @@ -227,7 +247,9 @@ class TestStartClone: cosy = FakeCosyVoiceService() svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3" + ): result = svc.start_clone( user_id="user-1", name="English Voice", @@ -245,7 +267,9 @@ class TestStartClone: cosy = FakeCosyVoiceService() svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3" + ): result = svc.start_clone( user_id="user-1", name="带元数据", @@ -372,14 +396,19 @@ class TestRetryClone: profile = make_profile(source_audio_url="https://example.com/orig.mp3") profile.mark_failed("previous error") repo = FakeProfileRepository(profile=profile) - cosy = FakeCosyVoiceService(submit_result={ - "voice_id": "voice_retry", - "status": "DEPLOYING", - "request_id": "req-retry", - }) + cosy = FakeCosyVoiceService( + submit_result={ + "voice_id": "voice_retry", + "status": "DEPLOYING", + "request_id": "req-retry", + } + ) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/orig.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/orig.mp3", + ): result = svc.retry_clone("profile-123", "user-1") # Should be processing again @@ -394,8 +423,9 @@ class TestRetryClone: cosy = FakeCosyVoiceService() svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", - side_effect=UrlSecurityError("internal IP")): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", side_effect=UrlSecurityError("internal IP") + ): result = svc.retry_clone("profile-123", "user-1") assert result.status == VoiceCloneStatus.FAILED.value @@ -409,7 +439,10 @@ class TestRetryClone: cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("still failing")) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/a.mp3", + ): result = svc.retry_clone("profile-123", "user-1") assert result.status == VoiceCloneStatus.FAILED.value @@ -419,14 +452,19 @@ class TestRetryClone: profile = make_profile(source_audio_url="https://example.com/a.mp3") profile.mark_failed("old error") repo = FakeProfileRepository(profile=profile) - cosy = FakeCosyVoiceService(submit_result={ - "voice_id": "voice_instant", - "status": "OK", - "request_id": "req-instant", - }) + cosy = FakeCosyVoiceService( + submit_result={ + "voice_id": "voice_instant", + "status": "OK", + "request_id": "req-instant", + } + ) svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy) - with patch("packages.application.voice_clone.workflow.validate_url_safety", return_value="https://safe.example.com/a.mp3"): + with patch( + "packages.application.voice_clone.workflow.validate_url_safety", + return_value="https://safe.example.com/a.mp3", + ): result = svc.retry_clone("profile-123", "user-1") assert result.status == VoiceCloneStatus.READY.value -- 2.54.0