"""CosyVoiceService 单元测试.""" from __future__ import annotations import time from unittest.mock import MagicMock, patch import httpx import pytest from packages.application.cosyvoice_service import ( CloneResult, CosyVoiceAuthError, CosyVoiceError, CosyVoiceService, CosyVoiceTimeoutError, SynthesizeResult, ) @pytest.fixture def mock_client() -> MagicMock: """Mock HTTP client.""" return MagicMock(spec=httpx.Client) @pytest.fixture def service(mock_client: MagicMock) -> CosyVoiceService: """Create CosyVoiceService with mocked HTTP client and config.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "sk-test-12345678" settings.cosyvoice_base_url = "https://dashscope.aliyuncs.com/api/v1" settings.cosyvoice_model = "cosyvoice-v3-flash" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "longxiaochun_v3" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings svc = CosyVoiceService(http_client=mock_client) svc.CLONE_POLL_INTERVAL = 0.001 # 加速测试 svc.RETRY_BACKOFF = 0.001 yield svc class TestInitConfig: """初始化配置测试.""" def test_base_url_with_old_text2audio_path_gets_normalized(self, mock_client: MagicMock) -> None: """旧版 base_url 带 text2audio 路径应自动修正.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "sk-test" settings.cosyvoice_base_url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio" settings.cosyvoice_model = "cosyvoice-v3-flash" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings svc = CosyVoiceService(http_client=mock_client) assert svc._base_url == "https://dashscope.aliyuncs.com/api/v1" def test_custom_params_override_config(self, mock_client: MagicMock) -> None: """显式传入参数覆盖配置.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "sk-config" settings.cosyvoice_base_url = "https://config.example.com" settings.cosyvoice_model = "config-model" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings svc = CosyVoiceService( api_key="sk-custom", base_url="https://custom.example.com/api/v1", model="custom-model", clone_model="custom-clone", http_client=mock_client, ) assert svc._api_key == "sk-custom" assert svc._base_url == "https://custom.example.com/api/v1" assert svc._model == "custom-model" assert svc._clone_model == "custom-clone" def test_context_manager(self, mock_client: MagicMock) -> None: """上下文管理器正常工作.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "sk-test" settings.cosyvoice_base_url = "https://example.com/api/v1" settings.cosyvoice_model = "test" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings svc = CosyVoiceService(http_client=mock_client) with svc as s: assert s is svc # 注入的client不会被关闭 mock_client.close.assert_not_called() def test_owns_client_gets_closed(self) -> None: """自有client在close时被关闭.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "sk-test" settings.cosyvoice_base_url = "https://example.com/api/v1" settings.cosyvoice_model = "test" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings with patch("packages.application.cosyvoice_service.httpx.Client") as mock_cls: mock_instance = MagicMock() mock_cls.return_value = mock_instance svc = CosyVoiceService() svc.close() mock_instance.close.assert_called_once() class TestSanitizePrefix: """_sanitize_prefix 清洗音色名称测试.""" def test_keeps_alphanumeric(self, service: CosyVoiceService) -> None: """保留字母数字.""" assert service._sanitize_prefix("hello123") == "hello123" def test_strips_special_chars(self, service: CosyVoiceService) -> None: """去除特殊字符(中文isalnum返回True会保留).""" assert service._sanitize_prefix("hello-world!@#") == "helloworld" def test_truncates_to_10_chars(self, service: CosyVoiceService) -> None: """超长截断到10字符.""" assert service._sanitize_prefix("abcdefghijklmnop") == "abcdefghij" def test_empty_after_cleaning_uses_default(self, service: CosyVoiceService) -> None: """清洗后为空用默认值clone.""" assert service._sanitize_prefix("!!!@@@###") == "clone" def test_empty_string(self, service: CosyVoiceService) -> None: """空字符串用默认值.""" assert service._sanitize_prefix("") == "clone" class TestSubmitCloneTask: """submit_clone_task 提交音色克隆测试.""" def test_empty_audio_url_raises(self, service: CosyVoiceService) -> None: """空audio_url抛ValueError.""" with pytest.raises(ValueError, match="audio_url 不能为空"): service.submit_clone_task(audio_url="") def test_no_api_key_raises_auth_error(self, mock_client: MagicMock) -> None: """无API Key抛认证错误.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "" settings.cosyvoice_base_url = "https://example.com/api/v1" settings.cosyvoice_model = "test" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings svc = CosyVoiceService(http_client=mock_client) with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"): svc.submit_clone_task(audio_url="https://example.com/audio.mp3") def test_success_returns_voice_id(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """成功返回voice_id和状态.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"voice_id": "voice_123", "status": "DEPLOYING"}, "request_id": "req_456", } mock_response.text = '{"output": {...}}' mock_client.request.return_value = mock_response result = service.submit_clone_task( audio_url="https://example.com/audio.mp3", voice_name="myvoice", ) assert result["voice_id"] == "voice_123" assert result["status"] == "DEPLOYING" assert result["request_id"] == "req_456" def test_voice_name_gets_sanitized(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """音色名称被清洗后作为prefix(中文isalnum为True会保留).""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response service.submit_clone_task( audio_url="https://example.com/a.mp3", voice_name="my-voice!", ) call_args = mock_client.request.call_args payload = call_args.kwargs["json"] # 去掉横杠和感叹号,只剩myvoice assert payload["input"]["prefix"] == "myvoice" def test_language_zh_cn_converts_to_zh(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """zh-CN转换为zh.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response service.submit_clone_task( audio_url="https://example.com/a.mp3", language="zh-CN", ) call_args = mock_client.request.call_args payload = call_args.kwargs["json"] assert payload["input"]["language_hints"] == ["zh"] def test_audio_url_signer_is_called(self, mock_client: MagicMock) -> None: """配置了audio_url_signer时会被调用预签名.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "sk-test" settings.cosyvoice_base_url = "https://example.com/api/v1" settings.cosyvoice_model = "test" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings signer = MagicMock(return_value="https://signed.example.com/audio.mp3?token=xxx") svc = CosyVoiceService(http_client=mock_client, audio_url_signer=signer) mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response svc.submit_clone_task(audio_url="https://original.example.com/a.mp3") signer.assert_called_once_with("https://original.example.com/a.mp3") call_args = mock_client.request.call_args payload = call_args.kwargs["json"] assert payload["input"]["url"] == "https://signed.example.com/audio.mp3?token=xxx" def test_signer_failure_falls_back_to_original_url(self, mock_client: MagicMock) -> None: """预签名失败时回退到原始URL.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "sk-test" settings.cosyvoice_base_url = "https://example.com/api/v1" settings.cosyvoice_model = "test" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings signer = MagicMock(side_effect=RuntimeError("sign failed")) svc = CosyVoiceService(http_client=mock_client, audio_url_signer=signer) mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response svc.submit_clone_task(audio_url="https://original.example.com/a.mp3") call_args = mock_client.request.call_args payload = call_args.kwargs["json"] assert payload["input"]["url"] == "https://original.example.com/a.mp3" def test_no_voice_id_in_response_raises(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """API返回无voice_id时抛错.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"output": {}, "request_id": "r1"} mock_response.text = "" mock_client.request.return_value = mock_response with pytest.raises(CosyVoiceError, match="未返回 voice_id"): service.submit_clone_task(audio_url="https://example.com/a.mp3") def test_custom_target_model(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """自定义目标模型被传递.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response service.submit_clone_task( audio_url="https://example.com/a.mp3", target_model="cosyvoice-v3.5", ) call_args = mock_client.request.call_args payload = call_args.kwargs["json"] assert payload["input"]["target_model"] == "cosyvoice-v3.5" class TestQueryVoiceStatus: """query_voice_status 查询音色状态测试.""" def test_empty_voice_id_raises(self, service: CosyVoiceService) -> None: """空voice_id抛ValueError.""" with pytest.raises(ValueError, match="voice_id 不能为空"): service.query_voice_status("") def test_no_api_key_raises(self, mock_client: MagicMock) -> None: """无API Key抛认证错误.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "" settings.cosyvoice_base_url = "https://example.com/api/v1" settings.cosyvoice_model = "test" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings svc = CosyVoiceService(http_client=mock_client) with pytest.raises(CosyVoiceAuthError): svc.query_voice_status("v1") def test_success_returns_status(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """成功返回状态信息.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": { "status": "OK", "target_model": "cosyvoice-v3-flash", "gmt_create": "2024-01-01 00:00:00", "gmt_modified": "2024-01-01 00:01:00", "resource_link": "https://example.com/voice", }, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response result = service.query_voice_status("voice_123") assert result["status"] == "OK" assert result["target_model"] == "cosyvoice-v3-flash" assert result["gmt_create"] == "2024-01-01 00:00:00" assert result["resource_link"] == "https://example.com/voice" class TestCheckTaskStatus: """check_task_status 兼容接口测试.""" def test_delegates_to_query_voice_status(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """委托给query_voice_status并返回兼容格式.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"status": "DEPLOYING", "target_model": "m1"}, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response result = service.check_task_status("voice_456") assert result["status"] == "DEPLOYING" assert result["voice_id"] == "voice_456" assert result["message"] == "" class TestPollCloneTask: """poll_clone_task 轮询克隆状态测试.""" def test_success_after_several_attempts(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """几次DEPLOYING后变OK.""" responses = [] for _ in range(3): r = MagicMock() r.status_code = 200 r.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "r"} r.text = "" responses.append(r) ok_r = MagicMock() ok_r.status_code = 200 ok_r.json.return_value = {"output": {"status": "OK"}, "request_id": "r"} ok_r.text = "" responses.append(ok_r) mock_client.request.side_effect = responses result = service.poll_clone_task("v1", timeout=30.0) assert result["voice_id"] == "v1" assert mock_client.request.call_count == 4 def test_undeployed_raises_error(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """状态为UNDEPLOYED时抛错.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"output": {"status": "UNDEPLOYED"}, "request_id": "r"} mock_response.text = "" mock_client.request.return_value = mock_response with pytest.raises(CosyVoiceError, match="审核未通过"): service.poll_clone_task("v1", timeout=30.0) def test_timeout_raises(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """超时抛CosyVoiceTimeoutError.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "r"} mock_response.text = "" mock_client.request.return_value = mock_response with pytest.raises(CosyVoiceTimeoutError): service.poll_clone_task("v1", timeout=0.001) def test_max_attempts_exceeded_raises_timeout(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """超过最大轮询次数抛超时.""" service.CLONE_MAX_POLL_ATTEMPTS = 3 mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "r"} mock_response.text = "" mock_client.request.return_value = mock_response with pytest.raises(CosyVoiceTimeoutError, match="轮询次数超限"): service.poll_clone_task("v1", timeout=300.0) assert mock_client.request.call_count == 3 def test_unknown_status_continues_polling(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """未知状态继续轮询.""" unknown_r = MagicMock() unknown_r.status_code = 200 unknown_r.json.return_value = {"output": {"status": "WEIRD_STATE"}, "request_id": "r"} unknown_r.text = "" ok_r = MagicMock() ok_r.status_code = 200 ok_r.json.return_value = {"output": {"status": "OK"}, "request_id": "r"} ok_r.text = "" mock_client.request.side_effect = [unknown_r, ok_r] result = service.poll_clone_task("v1", timeout=30.0) assert result["voice_id"] == "v1" assert mock_client.request.call_count == 2 class TestCloneVoice: """clone_voice 完整克隆流程测试.""" def test_full_flow_submit_and_poll(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """提交+轮询完整流程.""" # 第一次:提交克隆 submit_r = MagicMock() submit_r.status_code = 200 submit_r.json.return_value = { "output": {"voice_id": "v_new", "status": "DEPLOYING"}, "request_id": "req_submit", } submit_r.text = "" # 第二次:轮询DEPLOYING poll_r1 = MagicMock() poll_r1.status_code = 200 poll_r1.json.return_value = {"output": {"status": "DEPLOYING"}, "request_id": "req_p1"} poll_r1.text = "" # 第三次:轮询OK poll_r2 = MagicMock() poll_r2.status_code = 200 poll_r2.json.return_value = {"output": {"status": "OK"}, "request_id": "req_p2"} poll_r2.text = "" mock_client.request.side_effect = [submit_r, poll_r1, poll_r2] result = service.clone_voice( audio_url="https://example.com/a.mp3", voice_name="test", timeout=30.0, ) assert isinstance(result, CloneResult) assert result.voice_id == "v_new" assert result.request_id == "req_submit" assert mock_client.request.call_count == 3 def test_already_ok_on_submit_returns_immediately(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """提交时已OK直接返回,不轮询.""" submit_r = MagicMock() submit_r.status_code = 200 submit_r.json.return_value = { "output": {"voice_id": "v_ok", "status": "OK"}, "request_id": "req_ok", } submit_r.text = "" mock_client.request.return_value = submit_r result = service.clone_voice(audio_url="https://example.com/a.mp3", timeout=30.0) assert result.voice_id == "v_ok" # 只调用了一次(提交),没有轮询 assert mock_client.request.call_count == 1 class TestSubmitSynthesizeTask: """submit_synthesize_task 提交语音合成测试.""" def test_empty_text_raises(self, service: CosyVoiceService) -> None: """空text抛ValueError.""" with pytest.raises(ValueError, match="text 不能为空"): service.submit_synthesize_task(text="", voice_id="v1") def test_empty_voice_id_raises(self, service: CosyVoiceService) -> None: """空voice_id抛ValueError.""" with pytest.raises(ValueError, match="voice_id 不能为空"): service.submit_synthesize_task(text="你好", voice_id="") def test_no_api_key_raises(self, mock_client: MagicMock) -> None: """无API Key抛认证错误.""" with patch("packages.application.cosyvoice_service.get_shared_settings") as mock_settings: settings = MagicMock() settings.cosyvoice_api_key = "" settings.cosyvoice_base_url = "https://example.com/api/v1" settings.cosyvoice_model = "test" settings.cosyvoice_sample_rate = 22050 settings.cosyvoice_format = "mp3" settings.cosyvoice_voice = "test" settings.cosyvoice_clone_model = "voice-enrollment" mock_settings.return_value = settings svc = CosyVoiceService(http_client=mock_client) with pytest.raises(CosyVoiceAuthError): svc.submit_synthesize_task(text="你好", voice_id="v1") def test_success_returns_audio_url(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """成功返回audio_url.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"audio": {"url": "https://example.com/out.mp3"}}, "request_id": "req_tts", } mock_response.text = "" mock_client.request.return_value = mock_response result = service.submit_synthesize_task(text="你好世界", voice_id="voice_001") assert result["audio_url"] == "https://example.com/out.mp3" assert result["request_id"] == "req_tts" assert result["task_id"] == "" # 同步接口无task_id def test_no_audio_url_raises(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """返回无audio_url时抛错.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"output": {"audio": {}}, "request_id": "r1"} mock_response.text = "" mock_client.request.return_value = mock_response with pytest.raises(CosyVoiceError, match="未返回 audio_url"): service.submit_synthesize_task(text="你好", voice_id="v1") def test_custom_params_passed_through(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """自定义参数被传递.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"audio": {"url": "https://example.com/out.wav"}}, "request_id": "r1", } mock_response.text = "" mock_client.request.return_value = mock_response service.submit_synthesize_task( text="你好", voice_id="v1", sample_rate=44100, format="wav", speed=1.5, volume=80, ) call_args = mock_client.request.call_args payload = call_args.kwargs["json"] assert payload["input"]["voice"] == "v1" assert payload["input"]["sample_rate"] == 44100 assert payload["input"]["format"] == "wav" assert payload["input"]["rate"] == 1.5 assert payload["input"]["volume"] == 80 class TestPollSynthesizeTask: """poll_synthesize_task 测试.""" def test_raises_error_synchronous_no_poll(self, service: CosyVoiceService) -> None: """同步接口不支持轮询,抛错.""" with pytest.raises(CosyVoiceError, match="无需轮询"): service.poll_synthesize_task("task_123") class TestSynthesizeSpeech: """synthesize_speech 语音合成测试.""" def test_returns_synthesize_result(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """返回SynthesizeResult对象.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "output": {"audio": {"url": "https://example.com/out.mp3"}}, "request_id": "req_tts", } mock_response.text = "" mock_client.request.return_value = mock_response result = service.synthesize_speech(text="你好", voice_id="v1") assert isinstance(result, SynthesizeResult) assert result.audio_url == "https://example.com/out.mp3" assert result.request_id == "req_tts" class TestCallApiRetry: """_call_api 重试逻辑测试.""" def test_500_retries_and_succeeds(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """500错误重试后成功.""" err_r = MagicMock() err_r.status_code = 500 err_r.text = "Internal Server Error" ok_r = MagicMock() ok_r.status_code = 200 ok_r.json.return_value = {"output": {}, "request_id": "r"} ok_r.text = "{}" mock_client.request.side_effect = [err_r, ok_r] result = service._call_api("POST", "/test", json={"k": "v"}) assert result["request_id"] == "r" assert mock_client.request.call_count == 2 def test_500_all_retries_exhausted(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """500重试耗尽抛错.""" err_r = MagicMock() err_r.status_code = 500 err_r.text = "Internal Server Error" mock_client.request.return_value = err_r with pytest.raises(CosyVoiceError, match="服务端错误"): service._call_api("POST", "/test") assert mock_client.request.call_count == service.MAX_RETRIES def test_401_raises_auth_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """401直接抛认证错误,不重试.""" err_r = MagicMock() err_r.status_code = 401 err_r.text = "Unauthorized" mock_client.request.return_value = err_r with pytest.raises(CosyVoiceAuthError): service._call_api("POST", "/test") assert mock_client.request.call_count == 1 def test_403_raises_auth_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """403直接抛认证错误,不重试.""" err_r = MagicMock() err_r.status_code = 403 err_r.text = "Forbidden" mock_client.request.return_value = err_r with pytest.raises(CosyVoiceAuthError): service._call_api("POST", "/test") assert mock_client.request.call_count == 1 def test_400_with_json_body_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """400带JSON错误体不重试.""" err_r = MagicMock() err_r.status_code = 400 err_r.json.return_value = {"code": "InvalidParameter", "message": "text too long"} err_r.text = '{"code": "InvalidParameter", "message": "text too long"}' mock_client.request.return_value = err_r with pytest.raises(CosyVoiceError, match="参数错误") as exc_info: service._call_api("POST", "/test") assert "InvalidParameter" in str(exc_info.value) assert mock_client.request.call_count == 1 def test_400_with_plain_text_body_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """400纯文本错误体不重试.""" err_r = MagicMock() err_r.status_code = 400 err_r.json.side_effect = ValueError("not json") err_r.text = "Bad Request: malformed input" mock_client.request.return_value = err_r with pytest.raises(CosyVoiceError, match="调用失败"): service._call_api("POST", "/test") assert mock_client.request.call_count == 1 def test_timeout_retries(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """超时错误重试.""" ok_r = MagicMock() ok_r.status_code = 200 ok_r.json.return_value = {"output": {}, "request_id": "r"} ok_r.text = "{}" mock_client.request.side_effect = [httpx.TimeoutException("timeout"), ok_r] result = service._call_api("POST", "/test") assert result["request_id"] == "r" assert mock_client.request.call_count == 2 def test_request_error_retries(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """请求错误重试.""" ok_r = MagicMock() ok_r.status_code = 200 ok_r.json.return_value = {"output": {}, "request_id": "r"} ok_r.text = "{}" mock_client.request.side_effect = [httpx.ConnectError("conn err"), ok_r] result = service._call_api("POST", "/test") assert result["request_id"] == "r" assert mock_client.request.call_count == 2 def test_other_status_code_no_retry(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """其他状态码不重试.""" err_r = MagicMock() err_r.status_code = 418 err_r.text = "I'm a teapot" mock_client.request.return_value = err_r with pytest.raises(CosyVoiceError, match="HTTP 418"): service._call_api("POST", "/test") assert mock_client.request.call_count == 1 def test_bearer_token_in_headers(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """Authorization header正确设置.""" ok_r = MagicMock() ok_r.status_code = 200 ok_r.json.return_value = {"output": {}, "request_id": "r"} ok_r.text = "{}" mock_client.request.return_value = ok_r service._call_api("POST", "/test") call_args = mock_client.request.call_args headers = call_args.kwargs["headers"] assert headers["Authorization"] == "Bearer sk-test-12345678" assert headers["Content-Type"] == "application/json" def test_url_built_correctly(self, service: CosyVoiceService, mock_client: MagicMock) -> None: """URL正确拼接.""" ok_r = MagicMock() ok_r.status_code = 200 ok_r.json.return_value = {"output": {}, "request_id": "r"} ok_r.text = "{}" mock_client.request.return_value = ok_r service._call_api("POST", "/services/test") call_args = mock_client.request.call_args assert call_args.kwargs["url"] == "https://dashscope.aliyuncs.com/api/v1/services/test" class TestListPresetVoices: """list_preset_voices 预置音色测试.""" def test_returns_preset_voices(self, service: CosyVoiceService) -> None: """返回预置音色列表.""" with patch("packages.application.cosyvoice_service.get_preset_voices") as mock_get: mock_get.return_value = [MagicMock(), MagicMock()] result = service.list_preset_voices() assert len(result) == 2 mock_get.assert_called_once() class TestErrorClasses: """错误类继承关系测试.""" def test_auth_error_inherits_from_cosyvoice_error(self) -> None: assert issubclass(CosyVoiceAuthError, CosyVoiceError) def test_timeout_error_inherits_from_cosyvoice_error(self) -> None: assert issubclass(CosyVoiceTimeoutError, CosyVoiceError)