7ef0366de0
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (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 / Validate - Migration (alembic) (push) Successful in 47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m26s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Successful in 8m41s
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 / CI Gate (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 / Build Staging API Image (push) Successful in 12m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m39s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m7s
feat: 预置音色 preview_url 动态生成 (#1222) - _resolve_preset_preview_url: 缓存优先+CosyVoice合成+降级fallback - _resolve_all_preset_preview_urls: ThreadPoolExecutor并行生成 - 修改list_preset_voices/list_voices_unified注入CosyVoiceService - 13个新单测,117个相关测试全绿
231 lines
9.0 KiB
Python
231 lines
9.0 KiB
Python
"""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
|