"""CosyVoiceService 单元测试。""" from __future__ import annotations import json from unittest.mock import MagicMock import httpx import pytest from packages.application.cosyvoice_service import ( CloneResult, CosyVoiceAuthError, CosyVoiceError, CosyVoiceService, CosyVoiceTimeoutError, SynthesizeResult, ) from packages.domain.preset_voices import PresetVoice def _make_service( *, api_key: str = "test-api-key", base_url: str = "https://test.cosyvoice.api", model: str = "cosyvoice-v1", http_client: httpx.Client | None = None, ) -> CosyVoiceService: """创建测试用 CosyVoiceService。""" return CosyVoiceService( api_key=api_key, base_url=base_url, model=model, http_client=http_client, ) def _mock_response( status_code: int = 200, json_data: dict | None = None, text: str = "", ) -> httpx.Response: """创建 mock HTTP 响应。""" # httpx.Response 需要 content 参数才能正确调用 .json() content = b"" if json_data is not None: content = json.dumps(json_data).encode("utf-8") elif text: content = text.encode("utf-8") return httpx.Response( status_code=status_code, content=content, request=httpx.Request("POST", "https://test.cosyvoice.api"), ) # ── list_preset_voices ─────────────────────────────────── class TestListPresetVoices: """测试预置音色列表。""" def test_returns_all_preset_voices(self) -> None: """返回所有预置音色。""" service = _make_service() voices = service.list_preset_voices() assert len(voices) == 8 assert all(isinstance(v, PresetVoice) for v in voices) def test_preset_voice_ids(self) -> None: """预置音色 ID 正确。""" service = _make_service() voices = service.list_preset_voices() voice_ids = [v.voice_id for v in voices] assert "longxiaochun" in voice_ids assert "longxiaoxia" in voice_ids assert "longxiaochen" in voice_ids assert "longyue" in voice_ids assert "longshu" in voice_ids assert "longjing" in voice_ids assert "longbo" in voice_ids assert "longtian" in voice_ids def test_preset_voice_has_required_fields(self) -> None: """预置音色包含必要字段。""" service = _make_service() voices = service.list_preset_voices() for voice in voices: assert voice.voice_id assert voice.name assert voice.gender in ("male", "female") assert voice.language == "zh-CN" # ── clone_voice ────────────────────────────────────────── class TestCloneVoice: """测试音色克隆。""" def test_clone_sync_success(self) -> None: """同步克隆成功(直接返回 voice_id)。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={ "request_id": "req-001", "output": {"voice_id": "clone-voice-001"}, } ) service = _make_service(http_client=mock_client) result = service.clone_voice( audio_url="https://example.com/audio.mp3", voice_name="我的音色", ) assert isinstance(result, CloneResult) assert result.voice_id == "clone-voice-001" assert result.request_id == "req-001" mock_client.request.assert_called_once() def test_clone_async_with_polling(self) -> None: """异步克隆(返回 task_id,轮询后成功)。""" mock_client = MagicMock(spec=httpx.Client) # 第一次调用:提交任务,返回 task_id submit_response = _mock_response( json_data={ "request_id": "req-001", "output": {"task_id": "task-abc123", "task_status": "PENDING"}, } ) # 第二次调用:查询状态 → RUNNING running_response = _mock_response( json_data={ "request_id": "req-002", "output": {"task_status": "RUNNING"}, } ) # 第三次调用:查询状态 → SUCCEEDED success_response = _mock_response( json_data={ "request_id": "req-003", "output": { "task_status": "SUCCEEDED", "voice_id": "clone-voice-async-001", }, } ) mock_client.request.side_effect = [ submit_response, running_response, success_response, ] service = _make_service(http_client=mock_client) service.POLL_INTERVAL = 0 # 测试中不等待 result = service.clone_voice( audio_url="https://example.com/audio.mp3", ) assert result.voice_id == "clone-voice-async-001" assert mock_client.request.call_count == 3 def test_clone_async_task_failed(self) -> None: """异步克隆任务失败。""" mock_client = MagicMock(spec=httpx.Client) submit_response = _mock_response( json_data={ "output": {"task_id": "task-fail"}, } ) failed_response = _mock_response( json_data={ "output": { "task_status": "FAILED", "message": "音频质量不达标", }, } ) mock_client.request.side_effect = [submit_response, failed_response] service = _make_service(http_client=mock_client) service.POLL_INTERVAL = 0 with pytest.raises(CosyVoiceError, match="音频质量不达标"): service.clone_voice(audio_url="https://example.com/bad.mp3") def test_clone_empty_audio_url_raises(self) -> None: """空 audio_url 抛出 ValueError。""" service = _make_service() with pytest.raises(ValueError, match="audio_url 不能为空"): service.clone_voice(audio_url="") def test_clone_no_api_key_raises_auth_error(self) -> None: """未配置 API Key 抛出 CosyVoiceAuthError。""" service = _make_service(api_key="") with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"): service.clone_voice(audio_url="https://example.com/audio.mp3") def test_clone_auth_failure(self) -> None: """API 认证失败(401)。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(status_code=401) service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceAuthError, match="认证失败"): service.clone_voice(audio_url="https://example.com/audio.mp3") def test_clone_client_error_no_retry(self) -> None: """客户端错误(400)不重试。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(status_code=400, text="Bad Request") service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceError, match="HTTP 400"): service.clone_voice(audio_url="https://example.com/audio.mp3") # 客户端错误不重试,只调用一次 assert mock_client.request.call_count == 1 def test_clone_server_error_retries(self) -> None: """服务端错误(500)重试。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(status_code=500) service = _make_service(http_client=mock_client) service.RETRY_BACKOFF = 0 # 测试中不等待 with pytest.raises(CosyVoiceError, match="服务端错误"): service.clone_voice(audio_url="https://example.com/audio.mp3") # 服务端错误重试 MAX_RETRIES 次 assert mock_client.request.call_count == service.MAX_RETRIES def test_clone_timeout_retries(self) -> None: """超时重试。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.side_effect = httpx.TimeoutException("timeout") service = _make_service(http_client=mock_client) service.RETRY_BACKOFF = 0 with pytest.raises(CosyVoiceTimeoutError): service.clone_voice(audio_url="https://example.com/audio.mp3") assert mock_client.request.call_count == service.MAX_RETRIES def test_clone_with_voice_name(self) -> None: """带 voice_name 参数。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(json_data={"output": {"voice_id": "v-001"}}) service = _make_service(http_client=mock_client) service.clone_voice( audio_url="https://example.com/audio.mp3", voice_name="测试音色", ) call_args = mock_client.request.call_args payload = call_args.kwargs.get("json") or call_args[1].get("json") assert payload["parameters"]["voice_name"] == "测试音色" def test_clone_no_task_id_or_voice_id_raises(self) -> None: """API 返回无效响应(无 task_id 也无 voice_id)。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(json_data={"output": {}}) service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceError, match="未返回 task_id 或 voice_id"): service.clone_voice(audio_url="https://example.com/audio.mp3") # ── submit_clone_task ──────────────────────────────────── class TestSubmitCloneTask: """测试 submit_clone_task(非阻塞提交)。""" def test_submit_async_returns_task_id(self) -> None: """异步模式:返回 task_id。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={ "request_id": "req-001", "output": {"task_id": "task-abc", "task_status": "PENDING"}, } ) service = _make_service(http_client=mock_client) result = service.submit_clone_task( audio_url="https://example.com/audio.mp3", voice_name="测试音色", ) assert result["task_id"] == "task-abc" assert result["voice_id"] == "" assert result["request_id"] == "req-001" mock_client.request.assert_called_once() def test_submit_sync_returns_voice_id(self) -> None: """同步模式:直接返回 voice_id。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={ "request_id": "req-002", "output": {"voice_id": "voice-sync-001"}, } ) service = _make_service(http_client=mock_client) result = service.submit_clone_task( audio_url="https://example.com/audio.mp3", ) assert result["task_id"] == "" assert result["voice_id"] == "voice-sync-001" assert result["request_id"] == "req-002" def test_submit_empty_audio_url_raises(self) -> None: """空 audio_url 抛出 ValueError。""" service = _make_service() with pytest.raises(ValueError, match="audio_url 不能为空"): service.submit_clone_task(audio_url="") def test_submit_no_api_key_raises(self) -> None: """未配置 API Key 抛出 CosyVoiceAuthError。""" service = _make_service(api_key="") with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"): service.submit_clone_task(audio_url="https://example.com/audio.mp3") def test_submit_no_task_id_or_voice_id_raises(self) -> None: """API 返回无效响应时抛出 CosyVoiceError。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(json_data={"output": {}}) service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceError, match="未返回 task_id 或 voice_id"): service.submit_clone_task(audio_url="https://example.com/audio.mp3") def test_submit_with_voice_name_in_payload(self) -> None: """voice_name 参数包含在请求体中。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(json_data={"output": {"task_id": "task-001"}}) service = _make_service(http_client=mock_client) service.submit_clone_task( audio_url="https://example.com/audio.mp3", voice_name="我的音色", language="en-US", ) call_args = mock_client.request.call_args payload = call_args.kwargs.get("json") or call_args[1].get("json") assert payload["parameters"]["voice_name"] == "我的音色" assert payload["parameters"]["language"] == "en-US" # ── check_task_status ──────────────────────────────────── class TestCheckTaskStatus: """测试 check_task_status(单次状态查询)。""" def test_check_succeeded(self) -> None: """查询成功状态。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={ "output": { "task_status": "SUCCEEDED", "voice_id": "voice-done-001", }, } ) service = _make_service(http_client=mock_client) result = service.check_task_status("task-abc") assert result["status"] == "SUCCEEDED" assert result["voice_id"] == "voice-done-001" assert result["message"] == "" def test_check_running(self) -> None: """查询运行中状态。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={ "output": {"task_status": "RUNNING"}, } ) service = _make_service(http_client=mock_client) result = service.check_task_status("task-abc") assert result["status"] == "RUNNING" assert result["voice_id"] == "" def test_check_failed_with_message(self) -> None: """查询失败状态,包含错误消息。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={ "output": { "task_status": "FAILED", "message": "音频质量不达标", }, } ) service = _make_service(http_client=mock_client) result = service.check_task_status("task-fail") assert result["status"] == "FAILED" assert result["message"] == "音频质量不达标" def test_check_no_api_key_raises(self) -> None: """未配置 API Key 抛出 CosyVoiceAuthError。""" service = _make_service(api_key="") with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"): service.check_task_status("task-abc") def test_check_uses_correct_path(self) -> None: """请求路径包含 task_id。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(json_data={"output": {"task_status": "PENDING"}}) service = _make_service(http_client=mock_client) service.check_task_status("task-xyz-123") call_args = mock_client.request.call_args url = call_args.kwargs.get("url") or call_args[1].get("url") or call_args[0][0] assert "/tasks/task-xyz-123" in url # ── synthesize_speech ──────────────────────────────────── class TestSynthesizeSpeech: """测试语音合成。""" def test_synthesize_sync_success(self) -> None: """同步合成成功(直接返回 audio_url)。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={ "request_id": "req-tts-001", "output": { "audio_url": "https://cdn.example.com/audio.mp3", "duration": 5.2, "file_size": 83200, }, } ) service = _make_service(http_client=mock_client) result = service.synthesize_speech( text="你好世界", voice_id="longxiaochun", ) assert isinstance(result, SynthesizeResult) assert result.audio_url == "https://cdn.example.com/audio.mp3" assert result.duration == 5.2 assert result.file_size == 83200 assert result.request_id == "req-tts-001" def test_synthesize_async_with_polling(self) -> None: """异步合成(返回 task_id,轮询后成功)。""" mock_client = MagicMock(spec=httpx.Client) submit_response = _mock_response( json_data={ "output": {"task_id": "task-tts-001", "task_status": "PENDING"}, } ) success_response = _mock_response( json_data={ "output": { "task_status": "SUCCEEDED", "audio_url": "https://cdn.example.com/async.mp3", "duration": 3.0, }, } ) mock_client.request.side_effect = [submit_response, success_response] service = _make_service(http_client=mock_client) service.POLL_INTERVAL = 0 result = service.synthesize_speech( text="异步合成测试", voice_id="longxiaoxia", ) assert result.audio_url == "https://cdn.example.com/async.mp3" assert result.duration == 3.0 assert mock_client.request.call_count == 2 def test_synthesize_async_task_failed(self) -> None: """异步合成任务失败。""" mock_client = MagicMock(spec=httpx.Client) submit_response = _mock_response(json_data={"output": {"task_id": "task-tts-fail"}}) failed_response = _mock_response( json_data={ "output": { "task_status": "FAILED", "message": "文本过长", }, } ) mock_client.request.side_effect = [submit_response, failed_response] service = _make_service(http_client=mock_client) service.POLL_INTERVAL = 0 with pytest.raises(CosyVoiceError, match="文本过长"): service.synthesize_speech( text="超长文本" * 10000, voice_id="longxiaochun", ) def test_synthesize_empty_text_raises(self) -> None: """空 text 抛出 ValueError。""" service = _make_service() with pytest.raises(ValueError, match="text 不能为空"): service.synthesize_speech(text="", voice_id="longxiaochun") def test_synthesize_empty_voice_id_raises(self) -> None: """空 voice_id 抛出 ValueError。""" service = _make_service() with pytest.raises(ValueError, match="voice_id 不能为空"): service.synthesize_speech(text="测试", voice_id="") def test_synthesize_no_api_key_raises(self) -> None: """未配置 API Key 抛出 CosyVoiceAuthError。""" service = _make_service(api_key="") with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"): service.synthesize_speech(text="测试", voice_id="longxiaochun") def test_synthesize_with_parameters(self) -> None: """带采样率、格式、语速参数。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response( json_data={"output": {"audio_url": "https://cdn.example.com/out.wav"}} ) service = _make_service(http_client=mock_client) service.synthesize_speech( text="参数测试", voice_id="longxiaochun", sample_rate=44100, format="wav", speed=1.5, ) call_args = mock_client.request.call_args payload = call_args.kwargs.get("json") or call_args[1].get("json") params = payload["parameters"] assert params["sample_rate"] == 44100 assert params["format"] == "wav" assert params["rate"] == 1.5 def test_synthesize_no_url_or_task_id_raises(self) -> None: """API 返回无效响应(无 audio_url 也无 task_id)。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(json_data={"output": {}}) service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceError, match="未返回 audio_url 或 task_id"): service.synthesize_speech(text="测试", voice_id="longxiaochun") def test_synthesize_server_error_retries(self) -> None: """服务端错误重试。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(status_code=502) service = _make_service(http_client=mock_client) service.RETRY_BACKOFF = 0 with pytest.raises(CosyVoiceError, match="服务端错误"): service.synthesize_speech(text="测试", voice_id="longxiaochun") assert mock_client.request.call_count == service.MAX_RETRIES def test_synthesize_timeout_retries(self) -> None: """超时重试。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.side_effect = httpx.TimeoutException("timeout") service = _make_service(http_client=mock_client) service.RETRY_BACKOFF = 0 with pytest.raises(CosyVoiceTimeoutError): service.synthesize_speech(text="测试", voice_id="longxiaochun") assert mock_client.request.call_count == service.MAX_RETRIES # ── 重试逻辑 ───────────────────────────────────────────── class TestRetryLogic: """测试重试逻辑。""" def test_retry_then_success(self) -> None: """第一次失败,第二次成功。""" mock_client = MagicMock(spec=httpx.Client) # 第一次:服务端错误 error_response = _mock_response(status_code=500) # 第二次:成功 success_response = _mock_response(json_data={"output": {"voice_id": "v-retry-ok"}}) mock_client.request.side_effect = [error_response, success_response] service = _make_service(http_client=mock_client) service.RETRY_BACKOFF = 0 result = service.clone_voice(audio_url="https://example.com/audio.mp3") assert result.voice_id == "v-retry-ok" assert mock_client.request.call_count == 2 def test_max_retries_exhausted(self) -> None: """达到最大重试次数后抛出异常。""" mock_client = MagicMock(spec=httpx.Client) mock_client.request.return_value = _mock_response(status_code=503) service = _make_service(http_client=mock_client) service.RETRY_BACKOFF = 0 with pytest.raises(CosyVoiceError): service.clone_voice(audio_url="https://example.com/audio.mp3") assert mock_client.request.call_count == service.MAX_RETRIES