Files
xiaoxia-saas/tests/unit/test_tts_synthesize_clone_voice.py
T
xiaoxia 61295b9c25
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 4m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m43s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m19s
AI Code Review / AI Code Review (pull_request) Failing after 6m39s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 41s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Unit Tests (push) Successful in 13m47s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m58s
CI/CD Pipeline / Production Browser E2E (push) Failing after 404h25m27s
CI/CD Pipeline / Deploy Production (push) Failing after 404h25m28s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 404h25m29s
CI/CD Pipeline / Build Production API Image (push) Failing after 404h25m30s
CI/CD Pipeline / CI Gate (push) Failing after 404h25m30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 404h32m7s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 404h32m11s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 404h32m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 404h32m19s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 404h32m15s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 404h32m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 404h32m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 404h32m20s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 404h33m16s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 404h38m52s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 404h34m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 404h38m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 404h38m58s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 404h39m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 404h39m4s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Failing after 404h39m8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 404h39m10s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 404h39m14s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 404h39m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 404h39m18s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 404h39m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 404h39m22s
CI/CD Pipeline / PR Build Web Image (push) Failing after 404h39m20s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 404h39m22s
CI/CD Pipeline / PR Build API Image (push) Failing after 404h39m20s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 404h39m24s
CI/CD Pipeline / Canary Release to Production (push) Failing after 404h59m36s
CI/CD Pipeline / Build Production Web Image (push) Failing after 404h59m37s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 405h6m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 405h6m27s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 405h6m27s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 405h7m24s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 405h13m2s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 405h13m8s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 405h13m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 405h13m20s
feat(tts): 正式合成支持克隆音色 + 保存到配音库改写 assets 素材体系 + 克隆接口支持 asset_id (#1556)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 18:46:06 +08:00

169 lines
6.2 KiB
Python

"""POST /tts/synthesize 克隆音色解析的单元测试(路由级)。
覆盖:voice_id 传克隆 profile UUID / 显式 voice_clone_profile_id /
预置音色+profile 同传时以克隆为准 / 403 / 400 未完成 / 404 / 预置透传。
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from packages.domain.tts_job import TTSJob, TTSJobStatus
class FakeCloneProfile:
def __init__(self, pid, user_id="user-1", voice_id="clone-voice-001"):
self.id = pid
self.user_id = user_id
self.voice_id = voice_id
class FakeCloneRepo:
def __init__(self, profiles):
self._profiles = {p.id: p for p in profiles}
def get(self, pid):
return self._profiles.get(pid)
def _make_job(voice_id, voice_clone_profile_id=""):
return TTSJob(
id="job-new",
user_id="user-1",
input_text="x",
voice_id=voice_id,
voice_model="",
project_id="",
voice_clone_profile_id=voice_clone_profile_id,
status=TTSJobStatus.PROCESSING,
output_audio_url="",
output_audio_key="",
duration=0.0,
file_size=0,
sample_rate=22050,
format="mp3",
error_message="",
retry_count=0,
max_retries=3,
metadata={},
started_at=None,
completed_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
def _build_app(clone_repo):
from fastapi import FastAPI
app = FastAPI()
from app.api.routes.tts import _get_repository, router
from app.auth import get_current_user
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
app.include_router(router, prefix="/tts")
fake_user = MagicMock()
fake_user.user.id = "user-1"
app.dependency_overrides[get_current_user] = lambda: fake_user
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: clone_repo
app.dependency_overrides[get_cosyvoice_service] = lambda: MagicMock()
app.dependency_overrides[_get_repository] = lambda: MagicMock()
return app
class TestSynthesizeCloneVoiceResolution:
def _post(self, client, payload):
captured = {}
def fake_execute(*args, **kwargs):
captured.update(kwargs)
return _make_job(kwargs["voice_id"], kwargs.get("voice_clone_profile_id", ""))
def fake_start(*args, **kwargs):
return _make_job(captured.get("voice_id", ""), captured.get("voice_clone_profile_id", ""))
with (
patch(
"packages.application.tts_job.use_cases.CreateTTSJobUseCase.execute",
side_effect=fake_execute,
),
patch(
"packages.application.tts_job.workflow.TTSWorkflowService.start_synthesis",
side_effect=fake_start,
),
patch("app.api.routes.tts.celery_app.send_task"),
):
resp = client.post("/tts/synthesize", json=payload)
return resp, captured
def test_voice_id_as_profile_uuid_resolves_to_clone_voice(self):
from fastapi.testclient import TestClient
profile = FakeCloneProfile("profile-uuid-1")
client = TestClient(_build_app(FakeCloneRepo([profile])))
resp, captured = self._post(client, {"text": "克隆音色", "voice_id": "profile-uuid-1"})
assert resp.status_code == 201, resp.text
assert captured["voice_id"] == "clone-voice-001"
assert captured["voice_clone_profile_id"] == "profile-uuid-1"
def test_explicit_profile_id_resolves(self):
from fastapi.testclient import TestClient
profile = FakeCloneProfile("profile-uuid-2")
client = TestClient(_build_app(FakeCloneRepo([profile])))
resp, captured = self._post(client, {"text": "显式", "voice_clone_profile_id": "profile-uuid-2"})
assert resp.status_code == 201, resp.text
assert captured["voice_id"] == "clone-voice-001"
def test_preset_voice_id_with_explicit_profile_uses_clone(self):
"""voice_id 传预置音色 + 显式克隆 profile(不一致)→ 必须以克隆 profile 为准。"""
from fastapi.testclient import TestClient
profile = FakeCloneProfile("profile-uuid-3")
client = TestClient(_build_app(FakeCloneRepo([profile])))
resp, captured = self._post(
client,
{"text": "混合", "voice_id": "longxiaochun_v2", "voice_clone_profile_id": "profile-uuid-3"},
)
assert resp.status_code == 201, resp.text
assert captured["voice_id"] == "clone-voice-001"
assert captured["voice_clone_profile_id"] == "profile-uuid-3"
def test_other_user_profile_returns_403(self):
from fastapi.testclient import TestClient
profile = FakeCloneProfile("profile-other", user_id="other-user")
client = TestClient(_build_app(FakeCloneRepo([profile])))
resp, _ = self._post(client, {"text": "越权", "voice_id": "profile-other"})
assert resp.status_code == 403
def test_unfinished_clone_returns_400(self):
from fastapi.testclient import TestClient
profile = FakeCloneProfile("profile-pending", voice_id="")
client = TestClient(_build_app(FakeCloneRepo([profile])))
resp, _ = self._post(client, {"text": "未完成", "voice_id": "profile-pending"})
assert resp.status_code == 400
assert "克隆尚未完成" in resp.json()["detail"]
def test_nonexistent_profile_returns_404(self):
from fastapi.testclient import TestClient
client = TestClient(_build_app(FakeCloneRepo([])))
resp, _ = self._post(client, {"text": "不存在", "voice_clone_profile_id": "no-such-profile"})
assert resp.status_code == 404
def test_preset_voice_passes_through(self):
"""预置音色(不命中任何 profile)→ voice_id 原样透传。"""
from fastapi.testclient import TestClient
client = TestClient(_build_app(FakeCloneRepo([])))
resp, captured = self._post(client, {"text": "预置", "voice_id": "longxiaochun_v2"})
assert resp.status_code == 201, resp.text
assert captured["voice_id"] == "longxiaochun_v2"
assert captured["voice_clone_profile_id"] == ""