c861e90289
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m40s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m23s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m45s
CI/CD Pipeline / Unit Tests (push) Successful in 10m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m21s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m22s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / CI Gate (push) Has been skipped
283 lines
10 KiB
Python
Executable File
283 lines
10 KiB
Python
Executable File
"""音色克隆试听接口单元测试 (#1195).
|
|
|
|
直接测试路由函数逻辑,mock 所有依赖。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from packages.application.cosyvoice_service import CosyVoiceError, SynthesizeResult
|
|
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
|
|
|
|
|
def _make_profile(**kwargs) -> VoiceCloneProfile:
|
|
defaults = {
|
|
"id": "clone_001",
|
|
"user_id": "user_001",
|
|
"name": "测试音色",
|
|
"description": "",
|
|
"source_audio_url": "",
|
|
"voice_id": "clone_voice_001",
|
|
"voice_model": "cosyvoice-v3",
|
|
"language": "zh-CN",
|
|
"gender": "female",
|
|
"status": VoiceCloneStatus.READY,
|
|
"error_message": "",
|
|
"retry_count": 0,
|
|
"max_retries": 3,
|
|
"metadata": {},
|
|
"created_at": datetime.now(timezone.utc),
|
|
"updated_at": datetime.now(timezone.utc),
|
|
}
|
|
defaults.update(kwargs)
|
|
return VoiceCloneProfile(**defaults)
|
|
|
|
|
|
def _make_auth_user(user_id: str = "user_001") -> MagicMock:
|
|
user = MagicMock()
|
|
user.id = user_id
|
|
auth = MagicMock()
|
|
auth.user = user
|
|
return auth
|
|
|
|
|
|
class TestVoiceClonePreview:
|
|
"""克隆音色试听接口测试。"""
|
|
|
|
def _call_preview(self, profile, cosyvoice_mock, text="", user_id="user_001"):
|
|
"""调用路由函数,模拟 FastAPI 注入依赖。"""
|
|
from app.api.routes.voice_clones import get_voice_clone_preview
|
|
|
|
repo = MagicMock()
|
|
if profile is None:
|
|
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
|
|
|
repo.get.return_value = None
|
|
use_case_instance = MagicMock()
|
|
use_case_instance.execute.side_effect = VoiceCloneNotFoundError("not found")
|
|
else:
|
|
repo.get.return_value = profile
|
|
use_case_instance = MagicMock()
|
|
use_case_instance.execute.return_value = profile
|
|
|
|
# 替换 use case 构造
|
|
from packages.application.voice_clone.use_cases import GetVoiceCloneUseCase
|
|
|
|
original_init = GetVoiceCloneUseCase.__init__
|
|
original_execute = GetVoiceCloneUseCase.execute
|
|
|
|
def mock_init(self, repository):
|
|
self.repository = repository
|
|
|
|
def mock_execute(self, clone_id, uid):
|
|
if profile is None:
|
|
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
|
|
|
raise VoiceCloneNotFoundError("not found")
|
|
if profile.user_id != uid:
|
|
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
|
|
|
raise VoiceCloneNotFoundError("not found")
|
|
return profile
|
|
|
|
GetVoiceCloneUseCase.__init__ = mock_init
|
|
GetVoiceCloneUseCase.execute = mock_execute
|
|
|
|
try:
|
|
result = get_voice_clone_preview(
|
|
clone_id=profile.id if profile else "nonexistent",
|
|
text=text,
|
|
authenticated_user=_make_auth_user(user_id),
|
|
repository=repo,
|
|
cosyvoice=cosyvoice_mock,
|
|
)
|
|
return result
|
|
finally:
|
|
GetVoiceCloneUseCase.__init__ = original_init
|
|
GetVoiceCloneUseCase.execute = original_execute
|
|
|
|
def test_preview_success_ready_clone(self) -> None:
|
|
"""就绪的克隆音色可以正常试听。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile()
|
|
cosyvoice = MagicMock()
|
|
cosyvoice.synthesize_speech.return_value = SynthesizeResult(
|
|
audio_url="https://oss.example.com/preview/clone_001.mp3",
|
|
duration=3.5,
|
|
file_size=56000,
|
|
request_id="req_001",
|
|
)
|
|
|
|
result = self._call_preview(profile, cosyvoice)
|
|
|
|
assert result.clone_id == "clone_001"
|
|
assert result.voice_id == "clone_voice_001"
|
|
assert result.audio_url == "https://oss.example.com/preview/clone_001.mp3"
|
|
assert result.duration == 3.5
|
|
assert result.file_size == 56000
|
|
assert "克隆音色" in result.text
|
|
cosyvoice.synthesize_speech.assert_called_once()
|
|
call_kwargs = cosyvoice.synthesize_speech.call_args
|
|
assert call_kwargs.kwargs["voice_id"] == "clone_voice_001"
|
|
assert call_kwargs.kwargs["format"] == "mp3"
|
|
|
|
def test_preview_custom_text(self) -> None:
|
|
"""自定义试听文本。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile()
|
|
cosyvoice = MagicMock()
|
|
cosyvoice.synthesize_speech.return_value = SynthesizeResult(
|
|
audio_url="https://oss.example.com/preview/custom.mp3",
|
|
duration=2.0,
|
|
file_size=32000,
|
|
)
|
|
|
|
custom_text = "大家好,这是自定义试听文本。"
|
|
result = self._call_preview(profile, cosyvoice, text=custom_text)
|
|
|
|
assert result.text == custom_text
|
|
call_kwargs = cosyvoice.synthesize_speech.call_args
|
|
assert call_kwargs.kwargs["text"] == custom_text
|
|
|
|
def test_preview_not_found(self) -> None:
|
|
"""克隆音色不存在时返回 404。"""
|
|
cosyvoice = MagicMock()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
self._call_preview(None, cosyvoice)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
cosyvoice.synthesize_speech.assert_not_called()
|
|
|
|
def test_preview_not_ready_pending(self) -> None:
|
|
"""pending 状态的克隆音色不能试听。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile(status=VoiceCloneStatus.PENDING, voice_id="")
|
|
cosyvoice = MagicMock()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
self._call_preview(profile, cosyvoice)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "not ready" in exc_info.value.detail.lower() or "未就绪" in exc_info.value.detail
|
|
cosyvoice.synthesize_speech.assert_not_called()
|
|
|
|
def test_preview_not_ready_processing(self) -> None:
|
|
"""processing 状态的克隆音色不能试听。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
|
|
cosyvoice = MagicMock()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
self._call_preview(profile, cosyvoice)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
cosyvoice.synthesize_speech.assert_not_called()
|
|
|
|
def test_preview_not_ready_failed(self) -> None:
|
|
"""failed 状态的克隆音色不能试听。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile(status=VoiceCloneStatus.FAILED, error_message="超时")
|
|
cosyvoice = MagicMock()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
self._call_preview(profile, cosyvoice)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
cosyvoice.synthesize_speech.assert_not_called()
|
|
|
|
def test_preview_tts_failure(self) -> None:
|
|
"""TTS 合成失败返回 502。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile()
|
|
cosyvoice = MagicMock()
|
|
cosyvoice.synthesize_speech.side_effect = CosyVoiceError("API 调用失败")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
self._call_preview(profile, cosyvoice)
|
|
|
|
assert exc_info.value.status_code == 502
|
|
assert "TTS" in exc_info.value.detail
|
|
|
|
def test_preview_cache_default_text(self) -> None:
|
|
"""默认试听文本使用缓存。"""
|
|
from app.api.routes.voice_clones import CLONE_PREVIEW_CACHE_TTL, _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile()
|
|
cosyvoice = MagicMock()
|
|
cosyvoice.synthesize_speech.return_value = SynthesizeResult(
|
|
audio_url="https://oss.example.com/preview/cached.mp3",
|
|
duration=3.0,
|
|
file_size=48000,
|
|
)
|
|
|
|
# 第一次调用:合成
|
|
result1 = self._call_preview(profile, cosyvoice)
|
|
assert cosyvoice.synthesize_speech.call_count == 1
|
|
|
|
# 第二次调用:走缓存,不重复合成
|
|
result2 = self._call_preview(profile, cosyvoice)
|
|
assert cosyvoice.synthesize_speech.call_count == 1
|
|
assert result2.audio_url == result1.audio_url
|
|
assert result2.duration == result1.duration
|
|
|
|
def test_preview_custom_text_no_cache(self) -> None:
|
|
"""自定义文本不使用缓存。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile()
|
|
cosyvoice = MagicMock()
|
|
cosyvoice.synthesize_speech.side_effect = [
|
|
SynthesizeResult(audio_url="https://example.com/1.mp3", duration=2.0, file_size=32000),
|
|
SynthesizeResult(audio_url="https://example.com/2.mp3", duration=2.5, file_size=40000),
|
|
]
|
|
|
|
result1 = self._call_preview(profile, cosyvoice, text="自定义文本一")
|
|
result2 = self._call_preview(profile, cosyvoice, text="自定义文本二")
|
|
|
|
assert cosyvoice.synthesize_speech.call_count == 2
|
|
assert result1.audio_url != result2.audio_url
|
|
|
|
def test_preview_wrong_user(self) -> None:
|
|
"""非所有者不能访问他人克隆音色的试听。"""
|
|
from app.api.routes.voice_clones import _clone_preview_cache
|
|
|
|
_clone_preview_cache.clear()
|
|
|
|
profile = _make_profile(user_id="user_001")
|
|
cosyvoice = MagicMock()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
self._call_preview(profile, cosyvoice, user_id="user_002")
|
|
|
|
assert exc_info.value.status_code == 404
|
|
cosyvoice.synthesize_speech.assert_not_called()
|