"""Tests for dynamic preview URL resolution in voices routes. 覆盖 _resolve_preset_preview_url 和 _resolve_all_preset_preview_urls 的逻辑: - 缓存命中时直接返回 - 缓存失效时调用 CosyVoice 合成 - CosyVoice 失败时降级到硬编码 URL - 并行解析多个预置音色 """ from __future__ import annotations import time from unittest.mock import MagicMock, patch import pytest from packages.application.cosyvoice_service import CosyVoiceError, SynthesizeResult from packages.domain.preset_voices import PRESET_VOICES # ── fixtures ────────────────────────────────────────────────────────── @pytest.fixture() def mock_cosyvoice(): """构造一个模拟的 CosyVoiceService。""" svc = MagicMock() svc.synthesize_speech.return_value = SynthesizeResult( audio_url="https://fresh-url.example.com/preview.mp3", duration=2.5, file_size=40000, request_id="test-req-id", ) return svc @pytest.fixture() def failing_cosyvoice(): """构造一个总是失败的 CosyVoiceService。""" svc = MagicMock() svc.synthesize_speech.side_effect = CosyVoiceError("TTS API unavailable") return svc @pytest.fixture(autouse=True) def clear_cache(): """每个测试前清空预览缓存。""" from apps.api.app.api.routes import voices voices._preset_preview_cache.clear() yield voices._preset_preview_cache.clear() # ── _resolve_preset_preview_url ────────────────────────────────────── class TestResolvePresetPreviewUrl: """测试单个预置音色的 preview_url 解析。""" def test_cache_hit_returns_cached_url(self, mock_cosyvoice): """缓存有效时直接返回,不调用 CosyVoice。""" from apps.api.app.api.routes.voices import _preset_preview_cache, _resolve_preset_preview_url voice_id = "longxiaochun_v3" _preset_preview_cache[voice_id] = ("https://cached-url.example.com/audio.mp3", time.time()) result = _resolve_preset_preview_url(voice_id, "fallback.mp3", mock_cosyvoice) assert result == "https://cached-url.example.com/audio.mp3" mock_cosyvoice.synthesize_speech.assert_not_called() def test_cache_expired_triggers_synthesis(self, mock_cosyvoice): """缓存过期时重新调用 CosyVoice 合成。""" from apps.api.app.api.routes.voices import _preset_preview_cache, _resolve_preset_preview_url voice_id = "longxiaochun_v3" # 设置一个过期的缓存(10天前) _preset_preview_cache[voice_id] = ("https://old-url.mp3", time.time() - 10 * 86400) result = _resolve_preset_preview_url(voice_id, "fallback.mp3", mock_cosyvoice) assert result == "https://fresh-url.example.com/preview.mp3" mock_cosyvoice.synthesize_speech.assert_called_once() # 新 URL 应该被缓存 assert voice_id in _preset_preview_cache cached_url, _ = _preset_preview_cache[voice_id] assert cached_url == "https://fresh-url.example.com/preview.mp3" def test_cache_miss_triggers_synthesis(self, mock_cosyvoice): """缓存不存在时调用 CosyVoice 合成。""" from apps.api.app.api.routes.voices import _resolve_preset_preview_url voice_id = "longsanshu_v3" result = _resolve_preset_preview_url(voice_id, "fallback.mp3", mock_cosyvoice) assert result == "https://fresh-url.example.com/preview.mp3" mock_cosyvoice.synthesize_speech.assert_called_once() def test_synthesis_failure_returns_fallback(self, failing_cosyvoice): """CosyVoice 合成失败时降级返回硬编码 URL。""" from apps.api.app.api.routes.voices import _resolve_preset_preview_url voice_id = "longyue_v3" fallback = "https://hardcoded-fallback.example.com/audio.mp3" result = _resolve_preset_preview_url(voice_id, fallback, failing_cosyvoice) assert result == fallback failing_cosyvoice.synthesize_speech.assert_called_once() def test_nonexistent_voice_returns_fallback(self, mock_cosyvoice): """voice_id 不存在时直接返回 fallback。""" from apps.api.app.api.routes.voices import _resolve_preset_preview_url result = _resolve_preset_preview_url("nonexistent_voice_id", "fallback.mp3", mock_cosyvoice) assert result == "fallback.mp3" mock_cosyvoice.synthesize_speech.assert_not_called() def test_synthesis_call_uses_correct_params(self, mock_cosyvoice): """验证调用 CosyVoice 时参数正确。""" from apps.api.app.api.routes.voices import _resolve_preset_preview_url voice_id = "longshu_v3" _resolve_preset_preview_url(voice_id, "fallback.mp3", mock_cosyvoice) mock_cosyvoice.synthesize_speech.assert_called_once_with( text="你好,我是龙书,很高兴认识你。", voice_id="longshu_v3", format="mp3", speed=1.0, ) # ── _resolve_all_preset_preview_urls ───────────────────────────────── class TestResolveAllPresetPreviewUrls: """测试批量并行解析所有预置音色的 preview_url。""" def test_all_voices_resolved(self, mock_cosyvoice): """所有 8 个预置音色都能解析出 preview_url。""" from apps.api.app.api.routes.voices import _resolve_all_preset_preview_urls result = _resolve_all_preset_preview_urls(PRESET_VOICES, mock_cosyvoice) assert len(result) == 8 for preset in PRESET_VOICES: assert preset.voice_id in result assert result[preset.voice_id] == "https://fresh-url.example.com/preview.mp3" def test_partial_failure_still_returns_all(self, mock_cosyvoice): """部分合成失败时仍返回所有 voice_id(成功的用新 URL,失败的用 fallback)。""" from apps.api.app.api.routes.voices import _resolve_all_preset_preview_urls # 让第一个音色失败,其余成功 call_count = 0 original_side_effect = mock_cosyvoice.synthesize_speech.side_effect def side_effect_fn(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise CosyVoiceError("first call fails") return SynthesizeResult(audio_url="https://ok.mp3", duration=1.0) mock_cosyvoice.synthesize_speech.side_effect = side_effect_fn result = _resolve_all_preset_preview_urls(PRESET_VOICES, mock_cosyvoice) assert len(result) == 8 # 第一个失败的音色应该返回 fallback(硬编码 URL) first_voice = PRESET_VOICES[0] assert result[first_voice.voice_id] == first_voice.preview_url def test_all_fail_returns_empty_strings_or_fallbacks(self, failing_cosyvoice): """全部合成失败时,每个 voice_id 都有 fallback URL。""" from apps.api.app.api.routes.voices import _resolve_all_preset_preview_urls result = _resolve_all_preset_preview_urls(PRESET_VOICES, failing_cosyvoice) assert len(result) == 8 for preset in PRESET_VOICES: # 失败时返回硬编码的 fallback URL assert result[preset.voice_id] == preset.preview_url def test_empty_presets_returns_empty(self, mock_cosyvoice): """空列表时返回空字典。""" from apps.api.app.api.routes.voices import _resolve_all_preset_preview_urls result = _resolve_all_preset_preview_urls([], mock_cosyvoice) assert result == {} # ── _preset_to_unified_response with preview_url_map ───────────────── class TestPresetToUnifiedResponseWithMap: """测试 _preset_to_unified_response 支持 preview_url_map 参数。""" def test_with_preview_url_map(self): """传入 preview_url_map 时优先使用 map 中的 URL。""" from apps.api.app.api.routes.voices import _preset_to_unified_response preset = PRESET_VOICES[0] url_map = {preset.voice_id: "https://dynamic-url.example.com/audio.mp3"} result = _preset_to_unified_response(preset, url_map) assert result.preview_url == "https://dynamic-url.example.com/audio.mp3" def test_without_map_uses_hardcoded(self): """不传 preview_url_map 时使用硬编码 URL。""" from apps.api.app.api.routes.voices import _preset_to_unified_response preset = PRESET_VOICES[0] result = _preset_to_unified_response(preset) assert result.preview_url == preset.preview_url def test_map_missing_voice_falls_back(self): """preview_url_map 中没有对应 voice_id 时使用硬编码 URL。""" from apps.api.app.api.routes.voices import _preset_to_unified_response preset = PRESET_VOICES[0] url_map = {"other_voice_id": "https://other.mp3"} result = _preset_to_unified_response(preset, url_map) assert result.preview_url == preset.preview_url