adb3a2e269
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
861 lines
34 KiB
Python
Executable File
861 lines
34 KiB
Python
Executable File
"""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)
|