"""CosyVoiceService 单元测试 — 适配百炼 DashScope API.""" 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://dashscope.aliyuncs.com/api/v1", model: str = "cosyvoice-v3-flash", clone_model: str = "voice-enrollment", http_client: httpx.Client | None = None, audio_url_signer=None, ) -> CosyVoiceService: return CosyVoiceService( api_key=api_key, base_url=base_url, model=model, clone_model=clone_model, http_client=http_client, audio_url_signer=audio_url_signer, ) def _mock_response( status_code: int = 200, json_data: dict | None = None, text: str = "", method: str = "POST", ) -> httpx.Response: 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(method, "https://dashscope.aliyuncs.com/api/v1/test"), ) # ── list_preset_voices ─────────────────────────────────── class TestListPresetVoices: def test_returns_preset_voices(self) -> None: service = _make_service() voices = service.list_preset_voices() assert len(voices) >= 1 assert all(isinstance(v, PresetVoice) for v in voices) # ── submit_clone_task ──────────────────────────────────── class TestSubmitCloneTask: def test_submit_success_returns_voice_id(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, { "output": { "voice_id": "cosyvoice-v3-flash-clone-abc123", "status": "DEPLOYING", }, "usage": {"count": 1}, "request_id": "req-001", }, ) service = _make_service(http_client=mock_client) result = service.submit_clone_task( audio_url="https://example.com/audio.wav", voice_name="myvoice", ) assert result["voice_id"] == "cosyvoice-v3-flash-clone-abc123" assert result["status"] == "DEPLOYING" assert result["request_id"] == "req-001" # 验证请求参数 call_args = mock_client.request.call_args assert call_args.kwargs["method"] == "POST" assert "/services/audio/tts/customization" in call_args.kwargs["url"] payload = call_args.kwargs["json"] assert payload["model"] == "voice-enrollment" assert payload["input"]["action"] == "create_voice" assert payload["input"]["target_model"] == "cosyvoice-v3-flash" assert payload["input"]["prefix"] == "myvoice" assert payload["input"]["url"] == "https://example.com/audio.wav" assert payload["input"]["language_hints"] == ["zh"] def test_submit_with_custom_target_model(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, { "output": { "voice_id": "cosyvoice-v3-flash-clone-xyz", "status": "DEPLOYING", }, "request_id": "req-002", }, ) service = _make_service(http_client=mock_client) result = service.submit_clone_task( audio_url="https://example.com/audio.wav", target_model="cosyvoice-v3-flash", ) payload = mock_client.request.call_args.kwargs["json"] assert payload["input"]["target_model"] == "cosyvoice-v3-flash" assert result["voice_id"] == "cosyvoice-v3-flash-clone-xyz" def test_submit_empty_audio_url_raises(self) -> None: service = _make_service() with pytest.raises(ValueError, match="audio_url 不能为空"): service.submit_clone_task(audio_url="") def test_submit_no_api_key_raises_auth_error(self) -> None: service = _make_service(api_key="") with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"): service.submit_clone_task(audio_url="https://example.com/a.wav") def test_submit_no_voice_id_raises(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(200, {"output": {}}) service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceError, match="未返回 voice_id"): service.submit_clone_task(audio_url="https://example.com/a.wav") def test_submit_with_audio_url_signer(self) -> None: """传入 audio_url_signer 时,提交前会对音频URL预签名.""" mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, {"output": {"voice_id": "v123", "status": "DEPLOYING"}, "request_id": "r1"}, ) def signer(url: str) -> str: return f"{url}?signature=test" service = _make_service(http_client=mock_client, audio_url_signer=signer) service.submit_clone_task(audio_url="https://oss.example.com/audio.wav") payload = mock_client.request.call_args.kwargs["json"] assert payload["input"]["url"] == "https://oss.example.com/audio.wav?signature=test" def test_submit_signer_failure_falls_back_to_original(self) -> None: """signer 失败时回退到原始URL,不崩溃.""" mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, {"output": {"voice_id": "v123", "status": "DEPLOYING"}, "request_id": "r1"}, ) def failing_signer(url: str) -> str: raise RuntimeError("sign failed") service = _make_service(http_client=mock_client, audio_url_signer=failing_signer) result = service.submit_clone_task(audio_url="https://example.com/a.wav") assert result["voice_id"] == "v123" payload = mock_client.request.call_args.kwargs["json"] assert payload["input"]["url"] == "https://example.com/a.wav" # 原始URL def test_submit_prefix_sanitized(self) -> None: """voice_name 含特殊字符时清洗为合法 prefix.""" mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, {"output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1"} ) service = _make_service(http_client=mock_client) service.submit_clone_task(audio_url="https://e.com/a.wav", voice_name="我的音色-2024!") payload = mock_client.request.call_args.kwargs["json"] # 中文和特殊字符被过滤,剩下字母数字 assert ( payload["input"]["prefix"] == "2024" or payload["input"]["prefix"] == "clone" or len(payload["input"]["prefix"]) <= 10 ) def test_submit_auth_401_raises(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(401, text="Unauthorized") service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceAuthError, match="认证失败"): service.submit_clone_task(audio_url="https://e.com/a.wav") def test_submit_400_raises_with_code_message(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 400, {"code": "InvalidParameter", "message": "task can not be null"} ) service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceError, match="InvalidParameter"): service.submit_clone_task(audio_url="https://e.com/a.wav") # ── query_voice_status ────────────────────────────────── class TestQueryVoiceStatus: def test_query_deploying_status(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, { "output": { "status": "DEPLOYING", "target_model": "cosyvoice-v3-flash", "gmt_create": "2026-01-01T00:00:00Z", "gmt_modified": "2026-01-01T00:01:00Z", "resource_link": "https://...", }, "request_id": "req-003", }, ) service = _make_service(http_client=mock_client) result = service.query_voice_status("voice-123") assert result["status"] == "DEPLOYING" assert result["target_model"] == "cosyvoice-v3-flash" # 验证请求 payload = mock_client.request.call_args.kwargs["json"] assert payload["model"] == "voice-enrollment" assert payload["input"]["action"] == "query_voice" assert payload["input"]["voice_id"] == "voice-123" def test_query_ok_status(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, {"output": {"status": "OK", "target_model": "cosyvoice-v3-flash"}} ) service = _make_service(http_client=mock_client) result = service.query_voice_status("voice-123") assert result["status"] == "OK" def test_query_no_api_key_raises(self) -> None: service = _make_service(api_key="") with pytest.raises(CosyVoiceAuthError): service.query_voice_status("v1") def test_query_empty_voice_id_raises(self) -> None: service = _make_service() with pytest.raises(ValueError): service.query_voice_status("") # ── poll_clone_task ───────────────────────────────────── class TestPollCloneTask: def test_poll_ok_on_first_check(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, {"output": {"status": "OK", "target_model": "cosyvoice-v3-flash"}} ) service = _make_service(http_client=mock_client) # 减少轮询间隔加速测试 service.CLONE_POLL_INTERVAL = 0.01 result = service.poll_clone_task("voice-123", timeout=30.0) assert result["voice_id"] == "voice-123" def test_poll_deploying_then_ok(self) -> None: mock_client = MagicMock() # 第一次 DEPLOYING,第二次 OK mock_client.request.side_effect = [ _mock_response(200, {"output": {"status": "DEPLOYING"}}), _mock_response(200, {"output": {"status": "OK"}}), ] service = _make_service(http_client=mock_client) service.CLONE_POLL_INTERVAL = 0.01 result = service.poll_clone_task("voice-123", timeout=30.0) assert result["voice_id"] == "voice-123" assert mock_client.request.call_count == 2 def test_poll_undeployed_raises_error(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(200, {"output": {"status": "UNDEPLOYED"}}) service = _make_service(http_client=mock_client) service.CLONE_POLL_INTERVAL = 0.01 with pytest.raises(CosyVoiceError, match="审核未通过"): service.poll_clone_task("voice-123", timeout=30.0) def test_poll_timeout_raises(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(200, {"output": {"status": "DEPLOYING"}}) service = _make_service(http_client=mock_client) service.CLONE_POLL_INTERVAL = 0.01 service.CLONE_MAX_POLL_ATTEMPTS = 3 # 快速失败 with pytest.raises(CosyVoiceTimeoutError): service.poll_clone_task("voice-123", timeout=30.0) # ── clone_voice (阻塞) ────────────────────────────────── class TestCloneVoice: def test_clone_already_ok_returns_immediately(self) -> None: mock_client = MagicMock() # submit 直接返回 OK 状态 mock_client.request.return_value = _mock_response( 200, { "output": {"voice_id": "voice-ok", "status": "OK"}, "request_id": "req-ok", }, ) service = _make_service(http_client=mock_client) result = service.clone_voice(audio_url="https://e.com/a.wav") assert isinstance(result, CloneResult) assert result.voice_id == "voice-ok" assert result.request_id == "req-ok" # 只有一次调用(submit),没有 poll assert mock_client.request.call_count == 1 def test_clone_deploying_then_poll_ok(self) -> None: mock_client = MagicMock() mock_client.request.side_effect = [ # submit: 返回 DEPLOYING _mock_response(200, {"output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1"}), # poll 1: DEPLOYING _mock_response(200, {"output": {"status": "DEPLOYING"}}), # poll 2: OK _mock_response(200, {"output": {"status": "OK"}}), ] service = _make_service(http_client=mock_client) service.CLONE_POLL_INTERVAL = 0.01 result = service.clone_voice(audio_url="https://e.com/a.wav") assert isinstance(result, CloneResult) assert result.voice_id == "v1" assert mock_client.request.call_count == 3 # ── synthesize_speech ──────────────────────────────────── class TestSynthesizeSpeech: def test_synthesize_success_returns_audio_url(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, { "output": { "finish_reason": "stop", "audio": { "url": "https://dashscope-result.oss.com/output.mp3", "id": "audio-001", "expires_at": 1234567890, }, }, "usage": {"characters": 10}, "request_id": "req-syn-001", }, ) service = _make_service(http_client=mock_client) result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun_v3") assert isinstance(result, SynthesizeResult) assert result.audio_url == "https://dashscope-result.oss.com/output.mp3" assert result.request_id == "req-syn-001" # 验证请求 call_args = mock_client.request.call_args assert "/services/audio/tts/SpeechSynthesizer" in call_args.kwargs["url"] payload = call_args.kwargs["json"] assert payload["model"] == "cosyvoice-v3-flash" assert payload["input"]["text"] == "你好世界" assert payload["input"]["voice"] == "longxiaochun_v3" assert payload["input"]["format"] == "mp3" assert payload["input"]["sample_rate"] == 22050 assert payload["input"]["rate"] == 1.0 assert payload["input"]["volume"] == 50 def test_synthesize_with_custom_params(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, {"output": {"audio": {"url": "https://e.com/out.wav", "id": "a1"}}}, ) service = _make_service(http_client=mock_client) service.synthesize_speech( text="test", voice_id="v1", sample_rate=44100, format="wav", speed=1.5, volume=80, ) payload = mock_client.request.call_args.kwargs["json"] assert payload["input"]["sample_rate"] == 44100 assert payload["input"]["format"] == "wav" assert payload["input"]["rate"] == 1.5 assert payload["input"]["volume"] == 80 def test_synthesize_empty_text_raises(self) -> None: service = _make_service() with pytest.raises(ValueError, match="text 不能为空"): service.synthesize_speech(text="", voice_id="v1") def test_synthesize_empty_voice_raises(self) -> None: service = _make_service() with pytest.raises(ValueError, match="voice_id 不能为空"): service.synthesize_speech(text="hi", voice_id="") def test_synthesize_no_api_key_raises(self) -> None: service = _make_service(api_key="") with pytest.raises(CosyVoiceAuthError): service.synthesize_speech(text="hi", voice_id="v1") def test_synthesize_no_audio_url_raises(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(200, {"output": {}}) service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceError, match="未返回 audio_url"): service.synthesize_speech(text="hi", voice_id="v1") def test_synthesize_submit_returns_empty_task_id(self) -> None: """同步接口的 submit_synthesize_task 返回空 task_id 字段(兼容旧接口).""" mock_client = MagicMock() mock_client.request.return_value = _mock_response( 200, {"output": {"audio": {"url": "https://e.com/a.mp3"}}}, ) service = _make_service(http_client=mock_client) result = service.submit_synthesize_task(text="hi", voice_id="v1") assert result["task_id"] == "" # 同步接口无 task_id assert result["audio_url"] == "https://e.com/a.mp3" def test_synthesize_auth_401_raises(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(401, text="Unauthorized") service = _make_service(http_client=mock_client) with pytest.raises(CosyVoiceAuthError): service.synthesize_speech(text="hi", voice_id="v1") # ── retry logic ────────────────────────────────────────── class TestRetryLogic: def test_500_error_retries_then_succeeds(self) -> None: mock_client = MagicMock() mock_client.request.side_effect = [ _mock_response(500, text="Server Error"), _mock_response(502, text="Bad Gateway"), _mock_response(200, {"output": {"audio": {"url": "https://e.com/a.mp3"}}}), ] service = _make_service(http_client=mock_client) service.RETRY_BACKOFF = 0.01 # 加速 result = service.synthesize_speech(text="hi", voice_id="v1") assert result.audio_url == "https://e.com/a.mp3" assert mock_client.request.call_count == 3 def test_max_retries_exhausted_raises(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(500, text="Server Error") service = _make_service(http_client=mock_client) service.MAX_RETRIES = 2 service.RETRY_BACKOFF = 0.01 with pytest.raises(CosyVoiceError, match="服务端错误"): service.synthesize_speech(text="hi", voice_id="v1") assert mock_client.request.call_count == 2 # ── sanitize_prefix ───────────────────────────────────── class TestSanitizePrefix: def test_alphanumeric_kept(self) -> None: service = _make_service() assert service._sanitize_prefix("myvoice123") == "myvoice123" def test_special_chars_removed(self) -> None: service = _make_service() result = service._sanitize_prefix("my-voice_2!") # 特殊字符被移除,只保留字母数字 assert result == "myvoice2" def test_max_10_chars(self) -> None: service = _make_service() result = service._sanitize_prefix("abcdefghijklmnop") assert len(result) == 10 def test_empty_returns_clone(self) -> None: service = _make_service() assert service._sanitize_prefix("!!!???") == "clone" assert service._sanitize_prefix("") == "clone" # ── check_task_status (兼容旧接口) ────────────────────── class TestCheckTaskStatus: def test_check_task_status_uses_query_voice(self) -> None: mock_client = MagicMock() mock_client.request.return_value = _mock_response(200, {"output": {"status": "OK"}}) service = _make_service(http_client=mock_client) result = service.check_task_status("voice-123") assert result["status"] == "OK" assert result["voice_id"] == "voice-123" # 验证走的是 query_voice 路径 payload = mock_client.request.call_args.kwargs["json"] assert payload["input"]["action"] == "query_voice" # ── 配置与初始化 ───────────────────────────────────────── class TestServiceConfiguration: """测试 CosyVoiceService 配置与初始化逻辑.""" def test_base_url_old_text2audio_path_auto_fixed(self) -> None: """旧版 base_url 包含 text2audio 路径时,应自动修正为 /api/v1.""" service = CosyVoiceService( api_key="test-key", base_url="https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio", model="cosyvoice-v3-flash", ) # 应自动去掉 text2audio 后缀,保留到 /api/v1 assert service._base_url == "https://dashscope.aliyuncs.com/api/v1" def test_base_url_normal_unchanged(self) -> None: """正常的 base_url 不应被修改.""" url = "https://dashscope.aliyuncs.com/api/v1" service = CosyVoiceService( api_key="test-key", base_url=url, model="cosyvoice-v3-flash", ) assert service._base_url == url def test_base_url_workspace_domain_unchanged(self) -> None: """工作空间专属域名的 base_url 不应被修改.""" url = "https://workspace-xxx.cn-beijing.maas.aliyuncs.com/api/v1" service = CosyVoiceService( api_key="test-key", base_url=url, model="cosyvoice-v3-flash", ) assert service._base_url == url