"""音色克隆试听接口单元测试 (#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()