Files
xiaoxia 61295b9c25
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m43s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
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
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
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
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m58s
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

1134 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
TTS 合成 API 集成测试。
覆盖端点:
- POST /tts/synthesize — 创建 TTS 合成任务
- GET /tts/jobs — 列出 TTS 任务
- GET /tts/jobs/{job_id} — 获取 TTS 任务详情
- GET /tts/jobs/{job_id}/status — 获取 TTS 任务状态
- DELETE /tts/jobs/{job_id} — 删除 TTS 任务
- POST /tts/jobs/{job_id}/save-to-library — 保存到音色库
使用 FastAPI TestClient + dependency_overrides 模式,
mock repository 和 CosyVoice 服务。
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.api.routes.tts import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_cosyvoice_service,
get_project_repository,
get_user_repository,
get_voice_clone_profile_repository,
get_voice_library_repository,
)
from packages.domain.entities import User
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
# ---------------------------------------------------------------------------
# 1. 内存 Repository
# ---------------------------------------------------------------------------
class InMemoryTTSJobRepository:
"""内存中的 TTS 任务 Repository。"""
def __init__(self):
self._items: dict[str, TTSJob] = {}
def create(self, job: TTSJob) -> TTSJob:
self._items[job.id] = job
return job
def get(self, job_id: str) -> TTSJob | None:
return self._items.get(job_id)
def update(self, job: TTSJob) -> TTSJob:
self._items[job.id] = job
return job
def delete(self, job_id: str) -> bool:
if job_id in self._items:
del self._items[job_id]
return True
return False
def list_by_user(
self,
user_id: str,
*,
status=None,
limit: int = 50,
offset: int = 0,
) -> list[TTSJob]:
items = [j for j in self._items.values() if j.user_id == user_id]
if status:
status_str = status.value if hasattr(status, "value") else str(status)
items = [j for j in items if j.status.value == status_str]
items.sort(key=lambda j: j.created_at, reverse=True)
return items[offset : offset + limit]
def count_by_user(
self,
user_id: str,
*,
status=None,
) -> int:
items = [j for j in self._items.values() if j.user_id == user_id]
if status:
status_str = status.value if hasattr(status, "value") else str(status)
items = [j for j in items if j.status.value == status_str]
return len(items)
def list_by_profile(
self,
voice_clone_profile_id: str,
*,
status=None,
limit: int = 50,
offset: int = 0,
) -> list[TTSJob]:
items = [j for j in self._items.values() if j.voice_clone_profile_id == voice_clone_profile_id]
if status:
status_str = status.value if hasattr(status, "value") else str(status)
items = [j for j in items if j.status.value == status_str]
return items[offset : offset + limit]
class InMemoryVoiceCloneProfileRepository:
"""内存中的音色克隆档案 Repository(用于 TTS 测试)。"""
def __init__(self):
self._items: dict[str, VoiceCloneProfile] = {}
def create(self, profile):
self._items[profile.id] = profile
return profile
def get(self, profile_id: str):
return self._items.get(profile_id)
def update(self, profile):
self._items[profile.id] = profile
return profile
def delete(self, profile_id):
if profile_id in self._items:
del self._items[profile_id]
return True
return False
def list_by_user(self, user_id, **kwargs):
return [p for p in self._items.values() if p.user_id == user_id]
def count_by_user(self, user_id, **kwargs):
return len([p for p in self._items.values() if p.user_id == user_id])
def find_by_voice_id(self, voice_id):
return None
def find_profile_ids_by_voice_ids(self, voice_ids):
return {}
class InMemoryVoiceLibraryRepository:
"""内存中的配音库 Repository。"""
def __init__(self):
self._items = {}
def create(self, item):
self._items[item.id] = item
return item
def get(self, voice_id: str, user_id: str):
item = self._items.get(voice_id)
if item and item.user_id == user_id:
return item
return None
def update(self, item):
self._items[item.id] = item
return item
def delete(self, voice_id: str, user_id: str) -> bool:
item = self.get(voice_id, user_id)
if item:
del self._items[voice_id]
return True
return False
def list_by_user(self, user_id, **kwargs):
return [i for i in self._items.values() if i.user_id == user_id]
def count_by_user(self, user_id: str) -> int:
return len([i for i in self._items.values() if i.user_id == user_id])
class InMemoryUserRepository:
"""内存中的用户 Repository。"""
def __init__(self):
self._users = {}
def save(self, user):
self._users[user.id] = user
def find_by_id(self, user_id: str):
return self._users.get(user_id)
def find_by_email(self, email: str):
for u in self._users.values():
if u.email == email:
return u
return None
# ---------------------------------------------------------------------------
# 2. Mock CosyVoice 服务
# ---------------------------------------------------------------------------
class MockCosyVoiceService:
"""Mock CosyVoice 服务。"""
def __init__(self, *, fail_submit: bool = False):
self.fail_submit = fail_submit
self.submit_called = False
def submit_synthesize_task(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
self.submit_called = True
if self.fail_submit:
from packages.application.cosyvoice_service import CosyVoiceError
raise CosyVoiceError("模拟 CosyVoice 合成失败")
return {
"task_id": "mock-tts-task-123",
"status": "processing",
}
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
return {
"status": "completed",
"audio_url": "https://cdn.example.com/tts/output.mp3",
"duration": 5.5,
"file_size": 88000,
"sample_rate": 22050,
"format": "mp3",
}
def synthesize_speech(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
return {
"audio_url": "https://cdn.example.com/tts/output.mp3",
"duration": 5.5,
"file_size": 88000,
}
def submit_clone_task(self, **kwargs) -> dict:
return {"task_id": "clone-1", "status": "processing"}
def list_preset_voices(self) -> list:
return []
# ---------------------------------------------------------------------------
# 3. 辅助函数
# ---------------------------------------------------------------------------
def _make_user(**overrides) -> User:
defaults = dict(
id="user-test-001",
email="test@example.com",
display_name="Test User",
username="testuser",
subscription_plan="free",
subscription_status="active",
max_projects=3,
max_storage_gb=10,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
defaults.update(overrides)
return User(**defaults)
def _make_tts_job(
text: str = "你好,这是一段测试文本。",
user_id: str = "user-test-001",
status: TTSJobStatus = TTSJobStatus.PENDING,
**kwargs,
) -> TTSJob:
job = TTSJob.create(
user_id=user_id,
input_text=text,
voice_id=kwargs.get("voice_id", "voice-1"),
voice_model=kwargs.get("voice_model", "cosyvoice-v2"),
project_id=kwargs.get("project_id", ""),
voice_clone_profile_id=kwargs.get("voice_clone_profile_id", ""),
format=kwargs.get("format", "mp3"),
sample_rate=kwargs.get("sample_rate", 22050),
max_retries=kwargs.get("max_retries", 3),
metadata=kwargs.get("metadata", None),
)
# 设置状态
if status == TTSJobStatus.PROCESSING:
job.mark_processing()
elif status == TTSJobStatus.COMPLETED:
job.mark_processing()
job.mark_completed(
output_audio_url=kwargs.get("output_audio_url", "https://cdn.example.com/tts/out.mp3"),
output_audio_key=kwargs.get("output_audio_key", "tts/out.mp3"),
duration=kwargs.get("duration", 5.5),
file_size=kwargs.get("file_size", 88000),
)
elif status == TTSJobStatus.FAILED:
job.mark_processing()
job.mark_failed("合成失败")
elif status == TTSJobStatus.CANCELLED:
job.mark_cancelled()
return job
def _make_voice_clone_profile(
user_id: str = "user-test-001",
status: VoiceCloneStatus = VoiceCloneStatus.READY,
) -> VoiceCloneProfile:
profile = VoiceCloneProfile.create(
user_id=user_id,
name="测试克隆音色",
voice_model="cosyvoice-v2",
)
if status == VoiceCloneStatus.READY:
profile.mark_processing()
profile.mark_ready("clone-voice-001")
return profile
# ---------------------------------------------------------------------------
# 3b. 素材体系内存 Repositorysave-to-library 新链路)
# ---------------------------------------------------------------------------
class InMemoryAssetRepository:
"""内存 Asset 仓储(save-to-library 只用到 create/find_by_id)。"""
def __init__(self):
self._items: dict = {}
def create(self, asset):
self._items[asset.id] = asset
return asset
def find_by_id(self, asset_id: str):
return self._items.get(asset_id)
class InMemoryAssetLibraryRepository:
"""内存 AssetLibrary 仓储。"""
def __init__(self):
self._items: dict = {}
def create(self, library):
self._items[library.id] = library
return library
def get(self, library_id: str):
return self._items.get(library_id)
def find_by_id(self, library_id: str):
return self._items.get(library_id)
def find_by_project(self, project_id: str):
return [lib for lib in self._items.values() if lib.project_id == project_id]
class InMemoryProjectRepository2:
"""内存 Project 仓储(save-to-library 需要)。"""
def __init__(self):
self._items: dict = {}
def find_by_id(self, project_id: str):
return self._items.get(project_id)
def find_accessible_projects(self, user_id: str):
from packages.domain.entities import Project
projects = [p for p in self._items.values() if p.can_access(user_id)]
if projects:
return projects
# 没有任何项目时自动给一个默认项目(与前端 getOrCreateDefaultProject 行为对齐)
project = Project.create(owner_user_id=user_id, name="默认项目")
self._items[project.id] = project
return [project]
class MockStorageService:
"""Mock 存储:下载写出小文件,上传不做真实 OSS 操作。"""
def __init__(self):
self.uploaded_keys: list[str] = []
self.download_calls: list[str] = []
def download_asset(self, storage_key_or_url: str, local_path) -> bool:
self.download_calls.append(storage_key_or_url)
from pathlib import Path
path = Path(local_path)
path.write_bytes(b"FAKE-AUDIO-BYTES" * 100)
return True
def upload_file(self, file_or_path, storage_key: str, content_type: str = "application/octet-stream") -> str:
self.uploaded_keys.append(storage_key)
return f"https://cdn.example.com/{storage_key}"
def get_download_url(self, storage_key: str, expires_seconds: int = 3600) -> str:
return f"https://cdn.example.com/{storage_key}?signed=1"
def get_url(self, storage_key: str) -> str:
return f"https://cdn.example.com/{storage_key}"
# ---------------------------------------------------------------------------
# 4. Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tts_repo():
return InMemoryTTSJobRepository()
@pytest.fixture
def voice_clone_repo():
return InMemoryVoiceCloneProfileRepository()
@pytest.fixture
def voice_library_repo():
return InMemoryVoiceLibraryRepository()
@pytest.fixture
def asset_repo():
return InMemoryAssetRepository()
@pytest.fixture
def asset_library_repo():
return InMemoryAssetLibraryRepository()
@pytest.fixture
def project_repo2():
return InMemoryProjectRepository2()
@pytest.fixture
def storage_service():
return MockStorageService()
@pytest.fixture
def user_repo():
repo = InMemoryUserRepository()
repo.save(_make_user())
return repo
@pytest.fixture
def cosyvoice_service():
return MockCosyVoiceService()
@pytest.fixture
def client(
tts_repo,
voice_clone_repo,
voice_library_repo,
user_repo,
cosyvoice_service,
asset_repo,
asset_library_repo,
project_repo2,
storage_service,
):
"""创建带有依赖覆盖的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/tts")
def _override_current_user():
return AuthenticatedUser(user=_make_user())
def _override_tts_repo():
return tts_repo
test_app.dependency_overrides[get_current_user] = _override_current_user
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: voice_clone_repo
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
test_app.dependency_overrides[get_user_repository] = lambda: user_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_asset_library_repository] = lambda: asset_library_repo
test_app.dependency_overrides[get_project_repository] = lambda: project_repo2
test_app.dependency_overrides[get_storage_service] = lambda: storage_service
# 使用 FastAPI dependency_overrides 覆盖 TTS repository
from app.api.routes import tts as tts_module
test_app.dependency_overrides[tts_module._get_repository] = lambda: tts_repo
yield TestClient(test_app)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 5. POST /synthesize — 创建 TTS 合成任务
# ---------------------------------------------------------------------------
class TestCreateTTSJob:
"""创建 TTS 合成任务端点测试。"""
def test_create_with_valid_text(self, client, cosyvoice_service):
"""使用有效文本创建 TTS 任务。"""
resp = client.post(
"/tts/synthesize",
json={
"text": "你好,世界!",
"voice_id": "voice-1",
"voice_model": "cosyvoice-v2",
},
)
assert resp.status_code == 201
data = resp.json()
assert "job_id" in data
assert data["message"] == "合成任务已创建"
assert "status" in data
def test_create_persists_to_repository(self, client, tts_repo):
"""创建后任务保存到 repository。"""
resp = client.post("/tts/synthesize", json={"text": "持久化测试"})
job_id = resp.json()["job_id"]
saved = tts_repo.get(job_id)
assert saved is not None
assert saved.input_text == "持久化测试"
assert saved.user_id == "user-test-001"
def test_create_missing_text_returns_422(self, client):
"""缺少 text 返回 422。"""
resp = client.post("/tts/synthesize", json={})
assert resp.status_code == 422
def test_create_empty_text_returns_422(self, client):
"""空 text 返回 422。"""
resp = client.post("/tts/synthesize", json={"text": ""})
assert resp.status_code == 422
def test_create_with_custom_format(self, client):
"""支持指定输出格式。"""
for fmt in ["mp3", "wav", "pcm"]:
resp = client.post("/tts/synthesize", json={"text": "测试", "format": fmt})
assert resp.status_code == 201
def test_create_with_invalid_format_returns_422(self, client):
"""无效格式在 Pydantic 层校验返回 422。"""
# format 参数不在 TTSSynthesizeRequest schema 中,
# 或者有默认值/枚举校验。此处测试额外字段会被忽略或校验失败。
# 实际:schema 中 format 是可选的,有默认值,无效值会在领域层被捕获
# 但 API 仍返回 201,任务标记为 failed(与音色克隆行为一致)
resp = client.post("/tts/synthesize", json={"text": "测试", "format": "flac"})
# 格式不在请求 schema 中时,FastAPI 会忽略额外字段,任务正常创建
assert resp.status_code == 201
def test_create_with_metadata(self, client):
"""支持自定义 metadata。"""
resp = client.post(
"/tts/synthesize",
json={
"text": "元数据测试",
"metadata": {"source": "api", "version": "1.0"},
},
)
assert resp.status_code == 201
def test_create_with_voice_clone_profile_id(self, client, tts_repo, voice_clone_repo):
"""显式传 voice_clone_profile_id 创建 TTS
- 仅传 profile idvoice_id 为空)→ job.voice_id 解析为克隆 CosyVoice id
- voice_id 传预置音色 + 显式 profile id(两者不一致)→ 仍以克隆 profile 为准,
job.voice_id 必须是克隆 CosyVoice id,不能保留预置音色 id
"""
profile = _make_voice_clone_profile() # readyvoice_id="clone-voice-001"
voice_clone_repo.create(profile)
resp = client.post(
"/tts/synthesize",
json={
"text": "使用克隆音色",
"voice_clone_profile_id": profile.id,
},
)
assert resp.status_code == 201
job = tts_repo.get(resp.json()["job_id"])
assert job.voice_id == "clone-voice-001"
assert job.voice_clone_profile_id == profile.id
# voice_id 传预置音色 + 显式克隆 profile:合成音色必须以克隆 profile 为准
resp2 = client.post(
"/tts/synthesize",
json={
"text": "预置voice_id加克隆profile",
"voice_id": "longxiaochun_v2",
"voice_clone_profile_id": profile.id,
},
)
assert resp2.status_code == 201, resp2.text
job2 = tts_repo.get(resp2.json()["job_id"])
assert job2.voice_id == "clone-voice-001"
assert job2.voice_clone_profile_id == profile.id
def test_create_with_nonexistent_clone_profile_returns_404(self, client):
"""使用不存在的克隆档案返回 404。"""
resp = client.post(
"/tts/synthesize",
json={
"text": "测试",
"voice_clone_profile_id": "nonexistent-profile",
},
)
assert resp.status_code == 404
def test_create_with_other_user_clone_profile_returns_403(self, client, voice_clone_repo):
"""使用其他用户的克隆档案返回 403。"""
profile = _make_voice_clone_profile(user_id="other-user")
voice_clone_repo.create(profile)
resp = client.post(
"/tts/synthesize",
json={
"text": "越权测试",
"voice_clone_profile_id": profile.id,
},
)
assert resp.status_code == 403
def test_synthesize_with_clone_profile_in_voice_id(self, client, tts_repo, voice_clone_repo):
"""voice_id 直接传克隆 profile UUID(新前端流程):
命中 profile → 校验归属 → job.voice_id 存解析后的 CosyVoice voice_id
voice_clone_profile_id 记录该 profile。
"""
profile = _make_voice_clone_profile() # readyvoice_id="clone-voice-001"
voice_clone_repo.create(profile)
resp = client.post(
"/tts/synthesize",
json={"text": "克隆音色合成", "voice_id": profile.id},
)
assert resp.status_code == 201, resp.text
job = tts_repo.get(resp.json()["job_id"])
assert job.voice_id == "clone-voice-001"
assert job.voice_clone_profile_id == profile.id
def test_synthesize_with_other_user_clone_voice_id_returns_403(self, client, voice_clone_repo):
"""voice_id 传他人克隆 profile UUID → 403。"""
profile = _make_voice_clone_profile(user_id="other-user")
voice_clone_repo.create(profile)
resp = client.post(
"/tts/synthesize",
json={"text": "越权克隆音色", "voice_id": profile.id},
)
assert resp.status_code == 403, resp.text
def test_synthesize_with_unfinished_clone_voice_id_returns_400(self, client, voice_clone_repo):
"""voice_id 传克隆未完成(voice_id 为空)的 profile → 400。"""
from packages.domain.voice_clone_profile import VoiceCloneProfile
profile = VoiceCloneProfile.create(
user_id="user-test-001",
name="未完成克隆",
voice_model="cosyvoice-v2",
)
profile.mark_processing() # processing 状态,尚未 mark_readyvoice_id 为空
voice_clone_repo.create(profile)
resp = client.post(
"/tts/synthesize",
json={"text": "未完成克隆", "voice_id": profile.id},
)
assert resp.status_code == 400, resp.text
assert "克隆尚未完成" in resp.json()["detail"]
def test_synthesize_with_preset_voice_id_unaffected(self, client, tts_repo):
"""预置音色 voice_id 不匹配任何 profile 时走原流程,不受影响。"""
resp = client.post(
"/tts/synthesize",
json={"text": "预置音色", "voice_id": "longxiaochun_v2"},
)
assert resp.status_code == 201, resp.text
job = tts_repo.get(resp.json()["job_id"])
assert job.voice_id == "longxiaochun_v2"
assert job.voice_clone_profile_id == ""
# ---------------------------------------------------------------------------
# 6. GET /jobs — 列出 TTS 任务
# ---------------------------------------------------------------------------
class TestListTTSJobs:
"""列出 TTS 任务端点测试。"""
def test_empty_list(self, client):
"""无任务时返回空列表。"""
resp = client.get("/tts/jobs")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
assert data["page"] == 1
assert data["page_size"] == 20
def test_list_user_jobs(self, client, tts_repo):
"""只返回当前用户的任务。"""
j1 = _make_tts_job("任务1", "user-test-001")
j2 = _make_tts_job("任务2", "user-test-001")
j3 = _make_tts_job("他人任务", "other-user")
tts_repo.create(j1)
tts_repo.create(j2)
tts_repo.create(j3)
resp = client.get("/tts/jobs")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert len(data["items"]) == 2
def test_filter_by_status(self, client, tts_repo):
"""按状态筛选。"""
completed = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED)
failed = _make_tts_job("已失败", status=TTSJobStatus.FAILED)
tts_repo.create(completed)
tts_repo.create(failed)
resp = client.get("/tts/jobs?status=completed")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["status"] == "completed"
def test_pagination(self, client, tts_repo):
"""分页功能。"""
for i in range(5):
job = _make_tts_job(f"任务{i}")
tts_repo.create(job)
resp = client.get("/tts/jobs?page=1&page_size=2")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 5
assert data["page"] == 1
assert data["page_size"] == 2
assert len(data["items"]) == 2
resp2 = client.get("/tts/jobs?page=2&page_size=2")
assert resp2.json()["page"] == 2
assert len(resp2.json()["items"]) == 2
resp3 = client.get("/tts/jobs?page=3&page_size=2")
assert len(resp3.json()["items"]) == 1
def test_list_response_fields(self, client, tts_repo):
"""列表响应包含所有必需字段。"""
job = _make_tts_job("字段测试", status=TTSJobStatus.COMPLETED)
tts_repo.create(job)
resp = client.get("/tts/jobs")
item = resp.json()["items"][0]
for field in [
"id",
"user_id",
"input_text",
"voice_id",
"voice_model",
"status",
"output_audio_url",
"duration",
"format",
"error_message",
"retry_count",
"max_retries",
"created_at",
"updated_at",
]:
assert field in item, f"缺少字段: {field}"
# ---------------------------------------------------------------------------
# 7. GET /jobs/{job_id} — 获取 TTS 任务详情
# ---------------------------------------------------------------------------
class TestGetTTSJob:
"""获取 TTS 任务详情端点测试。"""
def test_get_existing_job(self, client, tts_repo):
"""获取存在的任务返回详情。"""
job = _make_tts_job("详情测试", voice_model="cosyvoice-v2")
tts_repo.create(job)
resp = client.get(f"/tts/jobs/{job.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == job.id
assert data["input_text"] == "详情测试"
assert data["voice_model"] == "cosyvoice-v2"
def test_get_nonexistent_returns_404(self, client):
"""获取不存在的任务返回 404。"""
resp = client.get("/tts/jobs/nonexistent-job-id")
assert resp.status_code == 404
assert "not found" in resp.json()["detail"].lower()
def test_get_other_user_job_returns_404(self, client, tts_repo):
"""获取其他用户的任务返回 404(安全隔离)。"""
job = _make_tts_job("他人任务", user_id="other-user")
tts_repo.create(job)
resp = client.get(f"/tts/jobs/{job.id}")
assert resp.status_code == 404
def test_get_completed_job(self, client, tts_repo):
"""获取已完成任务包含音频 URL 和时长。"""
job = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED, duration=10.5)
tts_repo.create(job)
resp = client.get(f"/tts/jobs/{job.id}")
data = resp.json()
assert data["status"] == "completed"
assert data["output_audio_url"] != ""
assert data["duration"] == 10.5
assert data["file_size"] > 0
def test_get_failed_job(self, client, tts_repo):
"""获取失败任务包含错误信息。"""
job = _make_tts_job("失败任务", status=TTSJobStatus.FAILED)
tts_repo.create(job)
resp = client.get(f"/tts/jobs/{job.id}")
data = resp.json()
assert data["status"] == "failed"
assert data["error_message"] != ""
# ---------------------------------------------------------------------------
# 8. GET /jobs/{job_id}/status — 获取 TTS 任务状态
# ---------------------------------------------------------------------------
class TestGetTTSJobStatus:
"""获取 TTS 任务状态端点测试。"""
def test_status_pending(self, client, tts_repo):
"""pending 状态。"""
job = _make_tts_job("pending", status=TTSJobStatus.PENDING)
tts_repo.create(job)
resp = client.get(f"/tts/jobs/{job.id}/status")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == job.id
assert data["status"] == "pending"
def test_status_completed(self, client, tts_repo):
"""completed 状态包含音频 URL。"""
job = _make_tts_job("completed", status=TTSJobStatus.COMPLETED)
tts_repo.create(job)
resp = client.get(f"/tts/jobs/{job.id}/status")
data = resp.json()
assert data["status"] == "completed"
assert data["output_audio_url"] != ""
assert data["duration"] > 0
def test_status_failed(self, client, tts_repo):
"""failed 状态包含错误信息。"""
job = _make_tts_job("failed", status=TTSJobStatus.FAILED)
tts_repo.create(job)
resp = client.get(f"/tts/jobs/{job.id}/status")
data = resp.json()
assert data["status"] == "failed"
assert data["error_message"] != ""
def test_status_nonexistent_returns_404(self, client):
"""获取不存在任务的状态返回 404。"""
resp = client.get("/tts/jobs/nonexistent/status")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# 9. DELETE /jobs/{job_id} — 删除 TTS 任务
# ---------------------------------------------------------------------------
class TestDeleteTTSJob:
"""删除 TTS 任务端点测试。"""
def test_delete_existing_job(self, client, tts_repo):
"""删除存在的任务返回 204。"""
job = _make_tts_job("待删除")
tts_repo.create(job)
resp = client.delete(f"/tts/jobs/{job.id}")
assert resp.status_code == 204
# 验证已删除
assert tts_repo.get(job.id) is None
def test_delete_nonexistent_returns_404(self, client):
"""删除不存在的任务返回 404。"""
resp = client.delete("/tts/jobs/nonexistent-job-id")
assert resp.status_code == 404
def test_delete_other_user_job_returns_404(self, client, tts_repo):
"""删除其他用户的任务返回 404(安全隔离)。"""
job = _make_tts_job("他人任务", user_id="other-user")
tts_repo.create(job)
resp = client.delete(f"/tts/jobs/{job.id}")
assert resp.status_code == 404
# 验证未被删除
assert tts_repo.get(job.id) is not None
def test_delete_idempotent(self, client, tts_repo):
"""删除后再次删除返回 404。"""
job = _make_tts_job("幂等测试")
tts_repo.create(job)
resp1 = client.delete(f"/tts/jobs/{job.id}")
assert resp1.status_code == 204
resp2 = client.delete(f"/tts/jobs/{job.id}")
assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# 10. POST /jobs/{job_id}/save-to-library — 保存到配音库
# ---------------------------------------------------------------------------
class TestSaveToLibrary:
"""保存到配音库端点测试。"""
def test_save_completed_job(self, client, tts_repo):
"""保存已完成的 TTS 任务到配音库。"""
job = _make_tts_job("保存测试", status=TTSJobStatus.COMPLETED, duration=5.5)
tts_repo.create(job)
resp = client.post(
f"/tts/jobs/{job.id}/save-to-library",
json={"name": "我的配音"},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "我的配音"
assert data["duration"] == 5.5
assert data["status"] == "completed"
assert "id" in data
assert "audio_url" in data
assert "voice_id" in data
assert "voice_name" in data
def test_save_pending_job_returns_400(self, client, tts_repo):
"""保存未完成的任务返回 400。"""
job = _make_tts_job("未完成", status=TTSJobStatus.PENDING)
tts_repo.create(job)
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
assert resp.status_code == 400
assert "not completed" in resp.json()["detail"].lower() or "完成" in resp.json()["detail"]
def test_save_failed_job_returns_400(self, client, tts_repo):
"""保存失败的任务返回 400。"""
job = _make_tts_job("失败", status=TTSJobStatus.FAILED)
tts_repo.create(job)
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
assert resp.status_code == 400
def test_save_nonexistent_job_returns_404(self, client):
"""保存不存在的任务返回 404。"""
resp = client.post("/tts/jobs/nonexistent/save-to-library")
assert resp.status_code == 404
def test_save_other_user_job_returns_404(self, client, tts_repo):
"""保存其他用户的任务返回 404。"""
job = _make_tts_job("他人任务", user_id="other-user", status=TTSJobStatus.COMPLETED)
tts_repo.create(job)
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
assert resp.status_code == 404
def test_save_auto_generates_name(self, client, tts_repo):
"""不指定名称时自动生成。"""
job = _make_tts_job("自动命名", status=TTSJobStatus.COMPLETED)
tts_repo.create(job)
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={})
assert resp.status_code == 201
data = resp.json()
assert data["name"] != ""
# 自动生成的名称应该以 TTS- 开头
assert data["name"].startswith("TTS-")
def test_save_creates_library_item(self, client, tts_repo, asset_repo, asset_library_repo):
"""保存后在素材体系(assets 表)中新增一条 ready 音频素材,并自动创建 voice 库。"""
job = _make_tts_job("入库测试", status=TTSJobStatus.COMPLETED, duration=6.0)
tts_repo.create(job)
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={"name": "入库"})
assert resp.status_code == 201, resp.text
data = resp.json()
# asset 已创建
asset = asset_repo.find_by_id(data["id"])
assert asset is not None
assert asset.status.value == "ready"
assert asset.file_type == "audio"
assert asset.duration == 6.0
assert (asset.metadata or {}).get("source") == "tts_job"
assert (asset.metadata or {}).get("tts_job_id") == job.id
assert asset.uploaded_by_user_id == "user-test-001"
assert asset.storage_key.startswith("uploads/voice/tts/")
# voice 素材库自动创建,asset 挂到该库
assert asset.library_id in asset_library_repo._items
voice_lib = asset_library_repo.get(asset.library_id)
assert voice_lib is not None
# ---------------------------------------------------------------------------
# 11. 跨端点场景
# ---------------------------------------------------------------------------
class TestTTSLifecycle:
"""TTS 完整生命周期测试。"""
def test_create_list_get_delete_flow(self, client, tts_repo):
"""创建 → 列表 → 详情 → 删除 完整流程。"""
# 1. 创建
create_resp = client.post("/tts/synthesize", json={"text": "完整流程测试"})
assert create_resp.status_code == 201
job_id = create_resp.json()["job_id"]
# 2. 列表
list_resp = client.get("/tts/jobs")
assert list_resp.json()["total"] == 1
# 3. 详情
detail_resp = client.get(f"/tts/jobs/{job_id}")
assert detail_resp.status_code == 200
assert detail_resp.json()["input_text"] == "完整流程测试"
# 4. 状态
status_resp = client.get(f"/tts/jobs/{job_id}/status")
assert status_resp.status_code == 200
# 5. 删除
del_resp = client.delete(f"/tts/jobs/{job_id}")
assert del_resp.status_code == 204
# 6. 删除后列表为空
list_resp2 = client.get("/tts/jobs")
assert list_resp2.json()["total"] == 0
def test_create_simulate_complete_save_to_library(self, client, tts_repo):
"""创建 → 模拟完成 → 保存到配音库 流程。"""
# 创建任务
create_resp = client.post("/tts/synthesize", json={"text": "入库流程"})
job_id = create_resp.json()["job_id"]
# 模拟 worker 完成
job = tts_repo.get(job_id)
assert job is not None
# 根据当前状态决定下一步:failed 先重置,pending 则转 processing,已是 processing 则跳过
if job.status == TTSJobStatus.FAILED:
job.prepare_retry()
job.mark_processing()
elif job.status == TTSJobStatus.PENDING:
job.mark_processing()
job.mark_completed(
output_audio_url="https://cdn.example.com/tts/final.mp3",
duration=8.0,
file_size=128000,
)
tts_repo.update(job)
# 确认完成
status_resp = client.get(f"/tts/jobs/{job_id}/status")
assert status_resp.json()["status"] == "completed"
# 保存到配音库
save_resp = client.post(
f"/tts/jobs/{job_id}/save-to-library",
json={"name": "最终配音"},
)
assert save_resp.status_code == 201
assert save_resp.json()["name"] == "最终配音"
assert save_resp.json()["duration"] == 8.0
def test_multiple_jobs_status_filter(self, client, tts_repo):
"""多个任务时按状态筛选正确。"""
# 创建不同状态的任务
for text, status in [
("任务A-完成", TTSJobStatus.COMPLETED),
("任务B-完成", TTSJobStatus.COMPLETED),
("任务C-失败", TTSJobStatus.FAILED),
("任务D-处理中", TTSJobStatus.PROCESSING),
]:
job = _make_tts_job(text, status=status)
tts_repo.create(job)
# 按状态筛选
completed_resp = client.get("/tts/jobs?status=completed")
assert completed_resp.json()["total"] == 2
failed_resp = client.get("/tts/jobs?status=failed")
assert failed_resp.json()["total"] == 1
processing_resp = client.get("/tts/jobs?status=processing")
assert processing_resp.json()["total"] == 1
if __name__ == "__main__":
pytest.main([__file__, "-v"])