"""统一音色列表接口单元测试 (#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