265ec7460b
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (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 / Frontend Unit Tests (push) Successful in 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m15s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m57s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m39s
CI/CD Pipeline / Unit Tests (push) Successful in 10m33s
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 12m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m16s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
267 lines
8.9 KiB
Python
Executable File
267 lines
8.9 KiB
Python
Executable File
"""统一音色列表接口单元测试 (#1196).
|
|
|
|
验证 /voices?type=clone 从 voice_clone_profile 读取克隆音色。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
|
|
|
|
|
def _make_clone_profile(**kwargs) -> VoiceCloneProfile:
|
|
defaults = {
|
|
"id": "clone_001",
|
|
"user_id": "user_001",
|
|
"name": "我的克隆音色",
|
|
"description": "测试用克隆音色",
|
|
"source_audio_url": "https://example.com/source.mp3",
|
|
"voice_id": "cosyvoice_clone_001",
|
|
"voice_model": "cosyvoice-v3",
|
|
"language": "zh-CN",
|
|
"gender": "female",
|
|
"status": VoiceCloneStatus.READY,
|
|
"error_message": "",
|
|
"retry_count": 0,
|
|
"max_retries": 3,
|
|
"metadata": {},
|
|
"created_at": datetime(2026, 7, 30, 10, 0, 0, tzinfo=timezone.utc),
|
|
"updated_at": datetime(2026, 7, 30, 10, 5, 0, tzinfo=timezone.utc),
|
|
}
|
|
defaults.update(kwargs)
|
|
return VoiceCloneProfile(**defaults)
|
|
|
|
|
|
def _make_auth_user(user_id: str = "user_001") -> MagicMock:
|
|
user = MagicMock()
|
|
user.id = user_id
|
|
auth = MagicMock()
|
|
auth.user = user
|
|
return auth
|
|
|
|
|
|
class TestVoicesCloneFromProfile:
|
|
"""测试 /voices?type=clone 从克隆音色档案读取。"""
|
|
|
|
def _call_list_voices(
|
|
self,
|
|
type_filter=None,
|
|
status_filter=None,
|
|
skip=0,
|
|
limit=50,
|
|
clone_profiles=None,
|
|
clone_count=0,
|
|
user_id="user_001",
|
|
):
|
|
"""调用 list_voices_unified 路由函数。"""
|
|
from app.api.routes.voices import list_voices_unified
|
|
|
|
# mock 依赖
|
|
voice_repo = MagicMock()
|
|
clone_profile_repo = MagicMock()
|
|
clone_profile_repo.list_by_user.return_value = clone_profiles or []
|
|
clone_profile_repo.count_by_user.return_value = clone_count
|
|
|
|
sign_url = MagicMock(side_effect=lambda u: u + "?signed=1")
|
|
|
|
result = list_voices_unified(
|
|
type=type_filter,
|
|
status_filter=status_filter,
|
|
skip=skip,
|
|
limit=limit,
|
|
authenticated_user=_make_auth_user(user_id),
|
|
voice_repository=voice_repo,
|
|
clone_profile_repository=clone_profile_repo,
|
|
sign_url=sign_url,
|
|
)
|
|
return result, clone_profile_repo, voice_repo
|
|
|
|
def test_type_clone_returns_ready_clones(self) -> None:
|
|
"""type=clone 返回 ready 状态的克隆音色。"""
|
|
profiles = [
|
|
_make_clone_profile(id="c1", name="音色1", voice_id="v1"),
|
|
_make_clone_profile(id="c2", name="音色2", voice_id="v2"),
|
|
]
|
|
|
|
result, clone_repo, voice_repo = self._call_list_voices(
|
|
type_filter="clone",
|
|
clone_profiles=profiles,
|
|
clone_count=2,
|
|
)
|
|
|
|
assert result.total == 2
|
|
assert result.clone_count == 2
|
|
assert result.preset_count == 0
|
|
assert len(result.items) == 2
|
|
|
|
# 验证字段
|
|
item = result.items[0]
|
|
assert item.id == "c1"
|
|
assert item.type == "clone"
|
|
assert item.name == "音色1"
|
|
assert item.voice_id == "v1"
|
|
assert item.voice_clone_profile_id == "c1"
|
|
assert item.status == "ready"
|
|
assert item.gender == "female"
|
|
assert item.language == "zh-CN"
|
|
assert item.user_id == "user_001"
|
|
|
|
# 调用了 clone_profile_repository
|
|
clone_repo.list_by_user.assert_called_once()
|
|
call_kwargs = clone_repo.list_by_user.call_args
|
|
assert call_kwargs.kwargs.get("status") == "ready"
|
|
|
|
# 没有调用 voice_repository(克隆音色不从配音库取)
|
|
voice_repo.execute.assert_not_called()
|
|
|
|
def test_type_clone_default_status_ready(self) -> None:
|
|
"""不传 status 时默认只返回 ready 状态。"""
|
|
result, clone_repo, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
clone_profiles=[],
|
|
clone_count=0,
|
|
)
|
|
|
|
clone_repo.list_by_user.assert_called_once()
|
|
call_kwargs = clone_repo.list_by_user.call_args
|
|
assert call_kwargs.kwargs.get("status") == "ready"
|
|
|
|
def test_type_clone_status_filter(self) -> None:
|
|
"""status 参数可以过滤特定状态。"""
|
|
result, clone_repo, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
status_filter="processing",
|
|
clone_profiles=[],
|
|
clone_count=0,
|
|
)
|
|
|
|
clone_repo.list_by_user.assert_called_once()
|
|
call_kwargs = clone_repo.list_by_user.call_args
|
|
assert call_kwargs.kwargs.get("status") == "processing"
|
|
|
|
def test_type_clone_status_all(self) -> None:
|
|
"""status=all 返回所有状态的克隆音色。"""
|
|
result, clone_repo, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
status_filter="all",
|
|
clone_profiles=[],
|
|
clone_count=0,
|
|
)
|
|
|
|
clone_repo.list_by_user.assert_called_once()
|
|
call_kwargs = clone_repo.list_by_user.call_args
|
|
assert call_kwargs.kwargs.get("status") is None
|
|
|
|
def test_type_preset_no_clone_data(self) -> None:
|
|
"""type=preset 时不查询克隆音色。"""
|
|
result, clone_repo, _ = self._call_list_voices(
|
|
type_filter="preset",
|
|
clone_profiles=[],
|
|
clone_count=0,
|
|
)
|
|
|
|
assert result.preset_count > 0
|
|
assert result.clone_count == 0
|
|
clone_repo.list_by_user.assert_not_called()
|
|
|
|
def test_type_none_returns_both(self) -> None:
|
|
"""不传 type 时返回预置 + 克隆。"""
|
|
profiles = [_make_clone_profile(id="c1", name="克隆音色")]
|
|
|
|
result, clone_repo, _ = self._call_list_voices(
|
|
type_filter=None,
|
|
clone_profiles=profiles,
|
|
clone_count=1,
|
|
)
|
|
|
|
assert result.preset_count > 0
|
|
assert result.clone_count == 1
|
|
assert result.total == result.preset_count + 1
|
|
clone_repo.list_by_user.assert_called_once()
|
|
|
|
def test_pagination(self) -> None:
|
|
"""分页参数正确传递。"""
|
|
result, clone_repo, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
skip=10,
|
|
limit=20,
|
|
clone_profiles=[],
|
|
clone_count=50,
|
|
)
|
|
|
|
clone_repo.list_by_user.assert_called_once()
|
|
call_kwargs = clone_repo.list_by_user.call_args
|
|
assert call_kwargs.kwargs.get("offset") == 10
|
|
assert call_kwargs.kwargs.get("limit") == 20
|
|
|
|
def test_empty_clone_list(self) -> None:
|
|
"""用户没有克隆音色时返回空列表。"""
|
|
result, clone_repo, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
clone_profiles=[],
|
|
clone_count=0,
|
|
)
|
|
|
|
assert result.total == 0
|
|
assert result.clone_count == 0
|
|
assert len(result.items) == 0
|
|
|
|
def test_clone_item_has_voice_id(self) -> None:
|
|
"""克隆音色条目包含 voice_id,前端可用于 TTS 合成。"""
|
|
profile = _make_clone_profile(voice_id="my_clone_voice_123")
|
|
result, _, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
clone_profiles=[profile],
|
|
clone_count=1,
|
|
)
|
|
|
|
assert result.items[0].voice_id == "my_clone_voice_123"
|
|
assert result.items[0].voice_provider == "cosyvoice-v3"
|
|
|
|
def test_clone_item_no_audio_url(self) -> None:
|
|
"""克隆音色没有预合成 audio_url(需通过 preview 接口试听)。"""
|
|
profile = _make_clone_profile()
|
|
result, _, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
clone_profiles=[profile],
|
|
clone_count=1,
|
|
)
|
|
|
|
item = result.items[0]
|
|
assert item.audio_url == ""
|
|
assert item.preview_url == ""
|
|
assert item.duration == 0
|
|
assert item.file_size == 0
|
|
|
|
def test_clone_item_has_profile_id(self) -> None:
|
|
"""克隆音色条目携带 voice_clone_profile_id,前端可用于调用 preview 接口。"""
|
|
profile = _make_clone_profile(id="profile_abc_123")
|
|
result, _, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
clone_profiles=[profile],
|
|
clone_count=1,
|
|
)
|
|
|
|
assert result.items[0].voice_clone_profile_id == "profile_abc_123"
|
|
|
|
def test_multiple_statuses(self) -> None:
|
|
"""不同状态的克隆音色返回正确的 status 字段。"""
|
|
for status in [
|
|
VoiceCloneStatus.PENDING,
|
|
VoiceCloneStatus.PROCESSING,
|
|
VoiceCloneStatus.READY,
|
|
VoiceCloneStatus.FAILED,
|
|
]:
|
|
profile = _make_clone_profile(id=f"c_{status.value}", status=status)
|
|
result, _, _ = self._call_list_voices(
|
|
type_filter="clone",
|
|
status_filter=status.value,
|
|
clone_profiles=[profile],
|
|
clone_count=1,
|
|
)
|
|
assert result.items[0].status == status.value
|