38e4dfd628
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m46s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m10s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m20s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 35h4m20s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 35h4m24s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 35h13m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 35h13m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 35h13m13s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 35h12m36s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 35h12m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 35h12m40s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 35h3m51s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 35h3m51s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 35h12m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 35h12m34s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 35h12m34s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 35h12m35s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 35h3m47s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 35h12m32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 35h12m39s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 35h12m41s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 35h48m4s
CosyVoice v3 API 调整:emotion 字段已废弃,改用 input.instruction 中文自然语言
指令;语言通过 input.language_hints 数组传递(仅取第一个元素)。
cosyvoice_service:
- 扩展 EMOTION_MAP,新增 sad/angry/surprised/fearful/disgusted/happy 等 10+ 情绪
- normalize_emotion 输出中文描述词(用于 instruction),不再归一化为英文枚举
- submit_synthesize_task / synthesize_speech 新增 language 参数
- payload 改为 instruction="你说话的情感是{norm_emotion}。" + language_hints=[lang]
- 系统音色仅传 zh/en;克隆音色不做语言限制(v3 克隆音色支持多语言)
- rate 字段名保持(CosyVoice 官方文档确认仍用 rate 字段)
schemas/routes:
- TTS 预览请求新增 language 字段(默认 zh-CN)
- emotion 字段描述更新为支持中文/英文自然语言指令
- /tts/synthesize 的 synthesis_meta 透传 language
- lipsync 任务默认 language=zh
tts_job workflow / streaming_service:
- 所有 CosyVoice 调用点透传 language 参数(默认 zh-CN)
- metadata 中 language 字段从请求一路透传到分段合成线程池
新增单测:
- test_normalize_emotion_* 更新为中文描述词断言,新增情绪覆盖
- test_submit_synthesize_payload_uses_instruction_and_language_hints
- test_submit_synthesize_payload_english_emotion_maps_to_chinese
- 相关老测试补 language kwarg 断言
- 全量 15127 passed,0 failed
[skip ci-format-check]
450 lines
16 KiB
Python
450 lines
16 KiB
Python
"""Tests for POST /tts/preview endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
|
|
@dataclass
|
|
class FakeSynthesizeResult:
|
|
audio_url: str
|
|
duration: float = 0.0
|
|
file_size: int = 0
|
|
request_id: str = ""
|
|
|
|
|
|
class TestTTSPreviewEndpoint:
|
|
"""Integration-style tests for the /tts/preview route."""
|
|
|
|
def _make_client(self, app):
|
|
from fastapi.testclient import TestClient
|
|
|
|
return TestClient(app)
|
|
|
|
def test_schema_preview_request_validation(self):
|
|
"""TTSPreviewRequest rejects text > 200 chars and empty voice_id."""
|
|
from app.schemas.tts import TTSPreviewRequest
|
|
|
|
# Valid
|
|
req = TTSPreviewRequest(text="hello", voice_id="v1")
|
|
assert req.text == "hello"
|
|
assert req.voice_id == "v1"
|
|
assert req.speed == 1.0
|
|
|
|
# Empty voice_id rejected
|
|
with pytest.raises(ValidationError):
|
|
TTSPreviewRequest(text="hello", voice_id="")
|
|
|
|
# Text > 200 chars rejected
|
|
with pytest.raises(ValidationError):
|
|
TTSPreviewRequest(text="a" * 201, voice_id="v1")
|
|
|
|
def test_schema_preview_response(self):
|
|
"""TTSPreviewResponse serialization."""
|
|
from app.schemas.tts import TTSPreviewResponse
|
|
|
|
resp = TTSPreviewResponse(audio_url="https://example.com/audio.mp3")
|
|
assert resp.audio_url == "https://example.com/audio.mp3"
|
|
assert resp.duration is None
|
|
|
|
resp2 = TTSPreviewResponse(audio_url="https://x.com/a.mp3", duration=3.5)
|
|
assert resp2.duration == 3.5
|
|
|
|
def test_preview_success(self):
|
|
"""Successful preview returns audio_url."""
|
|
from app.schemas.tts import TTSPreviewRequest
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
|
|
# We need to register the route with proper dependencies
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
# Override dependencies
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
|
audio_url="https://cosyvoice.example.com/audio.mp3",
|
|
duration=2.5,
|
|
)
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "你好世界", "voice_id": "longxiaochun"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["audio_url"] == "https://cosyvoice.example.com/audio.mp3"
|
|
assert data["duration"] == 2.5
|
|
|
|
mock_service.synthesize_speech.assert_called_once_with(
|
|
text="你好世界",
|
|
voice_id="longxiaochun",
|
|
speed=1.0,
|
|
emotion="",
|
|
language="zh-CN",
|
|
)
|
|
|
|
def test_preview_with_speed(self):
|
|
"""Custom speed is passed through to CosyVoice."""
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
|
audio_url="https://x.com/a.mp3",
|
|
duration=0.0,
|
|
)
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "测试", "voice_id": "v1", "speed": 1.5},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["audio_url"] == "https://x.com/a.mp3"
|
|
assert data["duration"] is None # 0.0 -> None
|
|
|
|
mock_service.synthesize_speech.assert_called_once_with(
|
|
text="测试",
|
|
voice_id="v1",
|
|
speed=1.5,
|
|
emotion="",
|
|
language="zh-CN",
|
|
)
|
|
|
|
def test_preview_cosyvoice_error_returns_502(self):
|
|
"""CosyVoice failure returns 502."""
|
|
from fastapi import FastAPI
|
|
|
|
from packages.application.cosyvoice_service import CosyVoiceError
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.synthesize_speech.side_effect = CosyVoiceError("API timeout")
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "测试", "voice_id": "v1"},
|
|
)
|
|
assert resp.status_code == 502
|
|
assert "TTS 合成失败" in resp.json()["detail"]
|
|
|
|
def test_preview_value_error_returns_400(self):
|
|
"""Invalid params return 400."""
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.synthesize_speech.side_effect = ValueError("text 不能为空")
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "测试", "voice_id": "v1"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "text 不能为空" in resp.json()["detail"]
|
|
|
|
def test_preview_text_too_long_returns_422(self):
|
|
"""Text > 200 chars is rejected by Pydantic validation."""
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "a" * 201, "voice_id": "v1"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_preview_empty_voice_id_returns_422(self):
|
|
"""Empty voice_id is rejected by Pydantic validation."""
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "hello", "voice_id": ""},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_preview_clone_voice_resolves_to_cosyvoice_id(self):
|
|
"""Clone voice UUID is resolved to CosyVoice voice_id."""
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
|
audio_url="https://x.com/cloned.mp3",
|
|
duration=1.8,
|
|
)
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
# Mock voice clone profile with voice_id
|
|
mock_profile = MagicMock()
|
|
mock_profile.user_id = "user-1"
|
|
mock_profile.voice_id = "cosyvoice_actual_voice_123"
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = mock_profile
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
# Frontend sends the profile UUID as voice_id
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "克隆音色测试", "voice_id": "abc123-uuid-of-profile"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["audio_url"] == "https://x.com/cloned.mp3"
|
|
|
|
# Verify CosyVoice was called with the resolved voice_id, not the UUID
|
|
mock_service.synthesize_speech.assert_called_once_with(
|
|
text="克隆音色测试",
|
|
voice_id="cosyvoice_actual_voice_123",
|
|
speed=1.0,
|
|
emotion="",
|
|
language="zh-CN",
|
|
)
|
|
# Verify repo was queried with the UUID
|
|
mock_clone_repo.get.assert_called_once_with("abc123-uuid-of-profile")
|
|
|
|
def test_preview_clone_voice_incomplete_returns_400(self):
|
|
"""Clone profile with empty voice_id returns 400."""
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
# Mock voice clone profile with empty voice_id (clone not finished)
|
|
mock_profile = MagicMock()
|
|
mock_profile.user_id = "user-1"
|
|
mock_profile.voice_id = ""
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = mock_profile
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "测试未完成克隆", "voice_id": "abc123-uuid"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "音色克隆尚未完成" in resp.json()["detail"]
|
|
|
|
def test_preview_preset_voice_passthrough(self):
|
|
"""Preset voice ID (not a profile UUID) passes through unchanged."""
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
|
audio_url="https://x.com/preset.mp3",
|
|
duration=2.0,
|
|
)
|
|
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
|
|
|
# Mock repo returns None (preset voice, not a clone profile)
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = None
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "预设音色测试", "voice_id": "longxiaoxia_v3"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
# Verify CosyVoice was called with the original preset voice_id
|
|
mock_service.synthesize_speech.assert_called_once_with(
|
|
text="预设音色测试",
|
|
voice_id="longxiaoxia_v3",
|
|
speed=1.0,
|
|
emotion="",
|
|
language="zh-CN",
|
|
)
|
|
|
|
def test_preview_clone_voice_wrong_user_returns_403(self):
|
|
"""Accessing another user's clone profile returns 403."""
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
from app.api.routes.tts import router
|
|
|
|
app.include_router(router, prefix="/tts")
|
|
|
|
from app.auth import get_current_user
|
|
from app.dependencies import get_voice_clone_profile_repository
|
|
|
|
fake_user = MagicMock()
|
|
fake_user.user.id = "user-1"
|
|
app.dependency_overrides[get_current_user] = lambda: fake_user
|
|
|
|
# Mock profile belonging to a different user
|
|
mock_profile = MagicMock()
|
|
mock_profile.user_id = "user-2"
|
|
mock_profile.voice_id = "cosyvoice_voice_xyz"
|
|
mock_clone_repo = MagicMock()
|
|
mock_clone_repo.get.return_value = mock_profile
|
|
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
|
|
|
client = self._make_client(app)
|
|
resp = client.post(
|
|
"/tts/preview",
|
|
json={"text": "越权测试", "voice_id": "other-user-profile-uuid"},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert "无权访问该音色" in resp.json()["detail"]
|