""" 配音库(Voice Library)Use Case 回归测试 测试目标: 1. CreateVoiceLibraryUseCase - 创建配音库条目,验证 voice_id 字段映射正确(PR#74 P0 bug 修复) 2. UpdateVoiceLibraryUseCase - 更新配音库条目,验证 voice_id 字段映射正确 3. 配额逻辑覆盖 - free=10, basic=100, premium=100 4. 边界条件与异常场景 """ from unittest.mock import MagicMock, Mock, call import pytest from packages.application.voice_library.commands import ( CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand, ) from packages.application.voice_library.use_cases import ( CreateVoiceLibraryUseCase, DeleteVoiceLibraryUseCase, GetVoiceLibraryUseCase, ListVoiceLibraryUseCase, NotFoundError, QuotaExceededError, UpdateVoiceLibraryUseCase, ) from packages.domain.voice_library import VoiceLibraryItem # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def mock_repo(): """创建 Mock 仓储""" repo = Mock() repo.count_by_user = Mock(return_value=0) repo.create = Mock(side_effect=lambda item: item) repo.update = Mock(side_effect=lambda item: item) repo.get = Mock(return_value=None) repo.delete = Mock(return_value=True) repo.list_by_user = Mock(return_value=[]) return repo @pytest.fixture def create_use_case(mock_repo): return CreateVoiceLibraryUseCase(repository=mock_repo) @pytest.fixture def update_use_case(mock_repo): return UpdateVoiceLibraryUseCase(repository=mock_repo) @pytest.fixture def sample_create_command(): """标准创建命令""" return CreateVoiceLibraryCommand( user_id="user-001", name="测试配音", text="你好世界", voice_provider="aliyun", voice_id="voice-abc-123", voice_name="小云", audio_url="https://oss.example.com/audio/abc.wav", duration=3.5, file_size=56000, status="completed", project_id="proj-001", tags=["测试", "中文"], metadata_={"source": "unit_test"}, ) @pytest.fixture def existing_voice_item(): """模拟已存在的配音条目""" return VoiceLibraryItem( id="existing-voice-001", user_id="user-001", name="旧配音", text="旧文本", voice_provider="old_provider", voice_id="old-voice-id", voice_name="旧声音", audio_url="https://oss.example.com/old.wav", duration=1.0, file_size=16000, status="completed", project_id="proj-001", tags=["旧"], metadata_={}, ) # =========================================================================== # 1. CreateVoiceLibraryUseCase 测试 # =========================================================================== class TestCreateVoiceLibraryUseCase: """配音库创建 UseCase 测试""" def test_create_success_all_fields(self, create_use_case, mock_repo, sample_create_command): """测试创建成功 - 所有字段完整传入""" result = create_use_case.execute(sample_create_command, plan_name="free") assert result is not None assert result.user_id == "user-001" assert result.name == "测试配音" assert result.text == "你好世界" assert result.voice_provider == "aliyun" assert result.voice_name == "小云" assert result.audio_url == "https://oss.example.com/audio/abc.wav" assert result.duration == 3.5 assert result.file_size == 56000 assert result.status == "completed" assert result.project_id == "proj-001" assert result.tags == ["测试", "中文"] assert result.metadata_ == {"source": "unit_test"} mock_repo.count_by_user.assert_called_once_with("user-001") mock_repo.create.assert_called_once() def test_create_voice_id_field_mapping(self, create_use_case, mock_repo): """ 【P0 回归】验证 voice_id 字段映射正确 PR#74 修复了 command.id 被错误使用的问题。 此测试确保 CreateVoiceLibraryCommand 中的 voice_id 字段 被正确传递到 VoiceLibraryItem 的 voice_id 属性上, 而非被其他字段(如 item 自身的 id)覆盖。 """ command = CreateVoiceLibraryCommand( user_id="user-001", name="voice_id 回归测试", voice_id="specific-voice-id-xyz", voice_provider="azure", voice_name="Azure Xiaoxiao", ) result = create_use_case.execute(command, plan_name="free") # 核心断言:voice_id 必须来自 command.voice_id assert result.voice_id == "specific-voice-id-xyz", "voice_id 应来自 command.voice_id,而非其他字段" # 同时确保 item 自身生成的 id 与 voice_id 不同 assert result.id != "specific-voice-id-xyz", "item.id(UUID)不应与 voice_id 混淆" def test_create_voice_id_empty_string(self, create_use_case, mock_repo): """测试 voice_id 为空字符串的合法场景""" command = CreateVoiceLibraryCommand( user_id="user-001", name="无 voice_id 配音", voice_id="", voice_provider="custom", ) result = create_use_case.execute(command, plan_name="free") assert result.voice_id == "" def test_create_default_values(self, create_use_case, mock_repo): """测试默认值填充""" command = CreateVoiceLibraryCommand( user_id="user-001", name="最小化创建", ) result = create_use_case.execute(command, plan_name="free") assert result.text == "" assert result.voice_provider == "" assert result.voice_id == "" assert result.voice_name == "" assert result.audio_url == "" assert result.duration == 0 assert result.file_size == 0 assert result.status == "completed" assert result.project_id is None assert result.tags == [] assert result.metadata_ == {} def test_create_generates_uuid(self, create_use_case, mock_repo): """测试创建时自动生成 UUID 作为 id""" command = CreateVoiceLibraryCommand( user_id="user-001", name="UUID 测试", ) result = create_use_case.execute(command, plan_name="free") assert result.id is not None assert len(result.id) == 32 # uuid4().hex 长度为 32 assert result.id.isalnum() # =========================================================================== # 2. 配额逻辑测试(Create 时的配额检查) # =========================================================================== class TestCreateVoiceLibraryQuota: """配音库创建配额检查测试""" def test_quota_free_plan_under_limit(self, create_use_case, mock_repo, sample_create_command): """free 套餐(上限10),当前 5 个,允许创建""" mock_repo.count_by_user.return_value = 5 result = create_use_case.execute(sample_create_command, plan_name="free") assert result is not None mock_repo.create.assert_called_once() def test_quota_free_plan_at_limit(self, create_use_case, mock_repo, sample_create_command): """free 套餐(上限10),当前 10 个,拒绝创建""" mock_repo.count_by_user.return_value = 10 with pytest.raises(QuotaExceededError) as exc_info: create_use_case.execute(sample_create_command, plan_name="free") assert exc_info.value.dimension == "max_voiceovers" assert exc_info.value.limit == 10 assert exc_info.value.used == 10 mock_repo.create.assert_not_called() def test_quota_free_plan_over_limit(self, create_use_case, mock_repo, sample_create_command): """free 套餐(上限10),当前 15 个,拒绝创建""" mock_repo.count_by_user.return_value = 15 with pytest.raises(QuotaExceededError) as exc_info: create_use_case.execute(sample_create_command, plan_name="free") assert exc_info.value.dimension == "max_voiceovers" assert exc_info.value.limit == 10 assert exc_info.value.used == 15 def test_quota_free_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command): """free 套餐(上限10),当前 9 个,允许创建(边界)""" mock_repo.count_by_user.return_value = 9 result = create_use_case.execute(sample_create_command, plan_name="free") assert result is not None mock_repo.create.assert_called_once() def test_quota_basic_plan_under_limit(self, create_use_case, mock_repo, sample_create_command): """basic 套餐(上限100),当前 50 个,允许创建""" mock_repo.count_by_user.return_value = 50 result = create_use_case.execute(sample_create_command, plan_name="basic") assert result is not None mock_repo.create.assert_called_once() def test_quota_basic_plan_at_limit(self, create_use_case, mock_repo, sample_create_command): """basic 套餐(上限100),当前 100 个,拒绝创建""" mock_repo.count_by_user.return_value = 100 with pytest.raises(QuotaExceededError) as exc_info: create_use_case.execute(sample_create_command, plan_name="basic") assert exc_info.value.dimension == "max_voiceovers" assert exc_info.value.limit == 100 assert exc_info.value.used == 100 def test_quota_basic_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command): """basic 套餐(上限100),当前 99 个,允许创建(边界)""" mock_repo.count_by_user.return_value = 99 result = create_use_case.execute(sample_create_command, plan_name="basic") assert result is not None mock_repo.create.assert_called_once() def test_quota_premium_plan_under_limit(self, create_use_case, mock_repo, sample_create_command): """premium 套餐(上限100),当前 50 个,允许创建""" mock_repo.count_by_user.return_value = 50 result = create_use_case.execute(sample_create_command, plan_name="premium") assert result is not None mock_repo.create.assert_called_once() def test_quota_premium_plan_at_limit(self, create_use_case, mock_repo, sample_create_command): """premium 套餐(上限100),当前 100 个,拒绝创建""" mock_repo.count_by_user.return_value = 100 with pytest.raises(QuotaExceededError) as exc_info: create_use_case.execute(sample_create_command, plan_name="premium") assert exc_info.value.dimension == "max_voiceovers" assert exc_info.value.limit == 100 assert exc_info.value.used == 100 def test_quota_premium_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command): """premium 套餐(上限100),当前 99 个,允许创建(边界)""" mock_repo.count_by_user.return_value = 99 result = create_use_case.execute(sample_create_command, plan_name="premium") assert result is not None mock_repo.create.assert_called_once() def test_quota_zero_usage(self, create_use_case, mock_repo, sample_create_command): """新用户零使用量,所有套餐均可创建""" mock_repo.count_by_user.return_value = 0 for plan in ["free", "basic", "premium"]: mock_repo.create.reset_mock() mock_repo.count_by_user.reset_mock() mock_repo.count_by_user.return_value = 0 result = create_use_case.execute(sample_create_command, plan_name=plan) assert result is not None, f"{plan} 套餐零使用量应允许创建" def test_quota_unknown_plan_defaults_to_zero(self, create_use_case, mock_repo, sample_create_command): """未知套餐名默认配额为 0,即使 0 使用量也无法创建""" mock_repo.count_by_user.return_value = 0 with pytest.raises(QuotaExceededError): create_use_case.execute(sample_create_command, plan_name="unknown_plan") def test_quota_exceeded_error_attributes(self, create_use_case, mock_repo, sample_create_command): """QuotaExceededError 异常属性完整性""" mock_repo.count_by_user.return_value = 10 with pytest.raises(QuotaExceededError) as exc_info: create_use_case.execute(sample_create_command, plan_name="free") err = exc_info.value assert hasattr(err, "dimension") assert hasattr(err, "limit") assert hasattr(err, "used") assert "max_voiceovers" in str(err) assert "10" in str(err) # =========================================================================== # 3. UpdateVoiceLibraryUseCase 测试 # =========================================================================== class TestUpdateVoiceLibraryUseCase: """配音库更新 UseCase 测试""" def test_update_success_all_fields(self, update_use_case, mock_repo, existing_voice_item): """测试全字段更新成功""" mock_repo.get.return_value = existing_voice_item command = UpdateVoiceLibraryCommand( id="existing-voice-001", user_id="user-001", name="更新后的名称", text="更新后的文本", voice_provider="new_provider", voice_id="new-voice-id-456", voice_name="新声音", audio_url="https://oss.example.com/new.wav", duration=5.0, file_size=80000, status="processing", tags=["新标签"], metadata_={"updated": True}, ) result = update_use_case.execute(command) assert result.name == "更新后的名称" assert result.text == "更新后的文本" assert result.voice_provider == "new_provider" assert result.voice_name == "新声音" assert result.audio_url == "https://oss.example.com/new.wav" assert result.duration == 5.0 assert result.file_size == 80000 assert result.status == "processing" assert result.tags == ["新标签"] assert result.metadata_ == {"updated": True} mock_repo.update.assert_called_once() def test_update_voice_id_field_mapping(self, update_use_case, mock_repo, existing_voice_item): """ 【P0 回归】验证 update 时 voice_id 字段映射正确 PR#74 修复了 API 路由层将 command.id 错误传给 voice_id 的 bug。 此测试确保 UpdateVoiceLibraryCommand 中 voice_id 字段 被正确写入 VoiceLibraryItem.voice_id,而非被 item.id 覆盖。 """ mock_repo.get.return_value = existing_voice_item command = UpdateVoiceLibraryCommand( id="existing-voice-001", user_id="user-001", voice_id="completely-different-voice-id", ) result = update_use_case.execute(command) # 核心断言:voice_id 应被更新为新值 assert result.voice_id == "completely-different-voice-id", "voice_id 应被更新为 command.voice_id 的值" # item 自身的 id 保持不变 assert result.id == "existing-voice-001" def test_update_partial_only_voice_id(self, update_use_case, mock_repo, existing_voice_item): """测试仅更新 voice_id 一个字段""" mock_repo.get.return_value = existing_voice_item command = UpdateVoiceLibraryCommand( id="existing-voice-001", user_id="user-001", voice_id="only-voice-id-changed", ) result = update_use_case.execute(command) assert result.voice_id == "only-voice-id-changed" # 其他字段保持不变 assert result.name == "旧配音" assert result.text == "旧文本" assert result.voice_provider == "old_provider" assert result.voice_name == "旧声音" assert result.audio_url == "https://oss.example.com/old.wav" assert result.duration == 1.0 assert result.file_size == 16000 def test_update_partial_only_name(self, update_use_case, mock_repo, existing_voice_item): """测试仅更新 name""" mock_repo.get.return_value = existing_voice_item command = UpdateVoiceLibraryCommand( id="existing-voice-001", user_id="user-001", name="仅改名", ) result = update_use_case.execute(command) assert result.name == "仅改名" assert result.voice_id == "old-voice-id" # voice_id 不变 def test_update_not_found(self, update_use_case, mock_repo): """测试更新不存在的条目""" mock_repo.get.return_value = None command = UpdateVoiceLibraryCommand( id="nonexistent-id", user_id="user-001", name="不存在", ) with pytest.raises(NotFoundError, match="nonexistent-id"): update_use_case.execute(command) mock_repo.update.assert_not_called() def test_update_wrong_user(self, update_use_case, mock_repo): """测试用户隔离 - 不能更新其他用户的条目""" mock_repo.get.return_value = None # repo 返回 None 表示找不到(不同 user_id) command = UpdateVoiceLibraryCommand( id="existing-voice-001", user_id="other-user-999", name="恶意修改", ) with pytest.raises(NotFoundError): update_use_case.execute(command) def test_update_none_fields_not_changed(self, update_use_case, mock_repo, existing_voice_item): """测试 None 字段不覆盖原有值""" mock_repo.get.return_value = existing_voice_item command = UpdateVoiceLibraryCommand( id="existing-voice-001", user_id="user-001", # 所有可选字段保持 None ) result = update_use_case.execute(command) # 所有字段应保持不变 assert result.name == "旧配音" assert result.text == "旧文本" assert result.voice_id == "old-voice-id" assert result.voice_provider == "old_provider" assert result.voice_name == "旧声音" assert result.audio_url == "https://oss.example.com/old.wav" assert result.duration == 1.0 assert result.file_size == 16000 assert result.status == "completed" def test_update_voice_id_empty_string(self, update_use_case, mock_repo, existing_voice_item): """测试 voice_id 更新为空字符串(合法场景:清除 voice_id)""" mock_repo.get.return_value = existing_voice_item command = UpdateVoiceLibraryCommand( id="existing-voice-001", user_id="user-001", voice_id="", ) result = update_use_case.execute(command) assert result.voice_id == "" # =========================================================================== # 4. DeleteVoiceLibraryUseCase 测试 # =========================================================================== class TestDeleteVoiceLibraryUseCase: """配音库删除 UseCase 测试""" def test_delete_success(self, mock_repo): """测试删除成功""" mock_repo.delete.return_value = True use_case = DeleteVoiceLibraryUseCase(repository=mock_repo) result = use_case.execute("voice-001", "user-001") assert result is True mock_repo.delete.assert_called_once_with("voice-001", "user-001") def test_delete_not_found(self, mock_repo): """测试删除不存在的条目""" mock_repo.delete.return_value = False use_case = DeleteVoiceLibraryUseCase(repository=mock_repo) result = use_case.execute("nonexistent", "user-001") assert result is False # =========================================================================== # 5. GetVoiceLibraryUseCase 测试 # =========================================================================== class TestGetVoiceLibraryUseCase: """配音库查询 UseCase 测试""" def test_get_existing(self, mock_repo): """测试查询存在的条目""" expected = VoiceLibraryItem( id="v-001", user_id="user-001", name="测试", voice_id="voice-xyz", ) mock_repo.get.return_value = expected use_case = GetVoiceLibraryUseCase(repository=mock_repo) result = use_case.execute("v-001", "user-001") assert result is not None assert result.id == "v-001" assert result.voice_id == "voice-xyz" mock_repo.get.assert_called_once_with("v-001", "user-001") def test_get_not_found(self, mock_repo): """测试查询不存在的条目""" mock_repo.get.return_value = None use_case = GetVoiceLibraryUseCase(repository=mock_repo) result = use_case.execute("nonexistent", "user-001") assert result is None # =========================================================================== # 6. ListVoiceLibraryUseCase 测试 # =========================================================================== class TestListVoiceLibraryUseCase: """配音库列表 UseCase 测试""" def test_list_default(self, mock_repo): """测试默认列表查询""" items = [ VoiceLibraryItem(id="v1", user_id="user-001", name="A"), VoiceLibraryItem(id="v2", user_id="user-001", name="B"), ] mock_repo.list_by_user.return_value = items mock_repo.count_by_user.return_value = 2 use_case = ListVoiceLibraryUseCase(repository=mock_repo) result_items, total = use_case.execute("user-001") assert len(result_items) == 2 assert total == 2 mock_repo.list_by_user.assert_called_once_with("user-001", status=None, skip=0, limit=50) def test_list_with_status_filter(self, mock_repo): """测试按状态筛选""" mock_repo.list_by_user.return_value = [] use_case = ListVoiceLibraryUseCase(repository=mock_repo) use_case.execute("user-001", status="completed", skip=10, limit=20) mock_repo.list_by_user.assert_called_once_with("user-001", status="completed", skip=10, limit=20) def test_list_empty(self, mock_repo): """测试空列表""" mock_repo.list_by_user.return_value = [] mock_repo.count_by_user.return_value = 0 use_case = ListVoiceLibraryUseCase(repository=mock_repo) items, total = use_case.execute("user-001") assert items == [] assert total == 0 # =========================================================================== # 7. voice_id 与 id 字段隔离专项回归测试 # =========================================================================== class TestVoiceIdFieldIsolation: """ PR#74 P0 Bug 回归:voice_id 与 item.id 字段隔离 原 bug:API 路由层误将 command.id(item 主键)用作 voice_id, 导致 voice_id 字段值错误。本测试类从 UseCase 层验证 这两个字段在整个 CRUD 生命周期中互不干扰。 """ def test_create_id_and_voice_id_are_independent(self, create_use_case, mock_repo): """创建时 id 自动生成,voice_id 来自 command""" command = CreateVoiceLibraryCommand( user_id="user-001", name="隔离测试", voice_id="tts-voice-001", voice_provider="openai", ) result = create_use_case.execute(command, plan_name="free") assert result.id != result.voice_id, "id 和 voice_id 应为不同值" assert result.voice_id == "tts-voice-001" assert len(result.id) == 32 # UUID hex def test_update_voice_id_does_not_change_id(self, update_use_case, mock_repo): """更新 voice_id 不影响 item 主键 id""" existing = VoiceLibraryItem( id="stable-id-001", user_id="user-001", name="测试", voice_id="old-voice", ) mock_repo.get.return_value = existing command = UpdateVoiceLibraryCommand( id="stable-id-001", user_id="user-001", voice_id="new-voice-999", ) result = update_use_case.execute(command) assert result.id == "stable-id-001", "item 主键 id 不应改变" assert result.voice_id == "new-voice-999", "voice_id 应被更新" def test_create_then_update_voice_id_preserves_id(self, create_use_case, update_use_case, mock_repo): """创建后再更新 voice_id,id 始终不变""" # 创建 create_cmd = CreateVoiceLibraryCommand( user_id="user-001", name="生命周期测试", voice_id="initial-voice", ) created = create_use_case.execute(create_cmd, plan_name="free") original_id = created.id # 更新 mock_repo.get.return_value = created update_cmd = UpdateVoiceLibraryCommand( id=original_id, user_id="user-001", voice_id="updated-voice", ) updated = update_use_case.execute(update_cmd) assert updated.id == original_id, "经过创建和更新,id 应保持一致" assert updated.voice_id == "updated-voice" assert updated.voice_id != "initial-voice"