diff --git a/tests/unit/test_feature_flags.py b/tests/unit/test_feature_flags.py new file mode 100755 index 000000000..304f6313b --- /dev/null +++ b/tests/unit/test_feature_flags.py @@ -0,0 +1,256 @@ +"""Feature Flags 单元测试""" + +from __future__ import annotations + +import pytest + +from packages.infrastructure.feature_flags import ( + FeatureFlag, + FeatureFlags, + FeatureScope, +) + + +class TestFeatureFlag: + """单个 FeatureFlag 测试""" + + def test_default_enabled(self): + """默认全局启用""" + flag = FeatureFlag(name="test_feature") + assert flag.is_enabled() is True + + def test_global_disabled(self): + """全局禁用""" + flag = FeatureFlag(name="test_feature", global_enabled=False) + assert flag.is_enabled() is False + + def test_plan_override_free_disabled(self): + """free 套餐被覆盖为禁用""" + flag = FeatureFlag( + name="test_feature", global_enabled=True, plan_overrides={"free": False} + ) + assert flag.is_enabled(user_plan="free") is False + assert flag.is_enabled(user_plan="basic") is True + assert flag.is_enabled(user_plan="premium") is True + + def test_plan_override_premium_only(self): + """仅 premium 可用""" + flag = FeatureFlag( + name="test_feature", + global_enabled=True, + plan_overrides={"free": False, "basic": False}, + ) + assert flag.is_enabled(user_plan="free") is False + assert flag.is_enabled(user_plan="basic") is False + assert flag.is_enabled(user_plan="premium") is True + + def test_user_override_priority_higher_than_plan(self): + """用户白名单优先级高于套餐""" + flag = FeatureFlag( + name="test_feature", + global_enabled=False, + plan_overrides={"free": False}, + user_overrides={"user_001": True}, + ) + # 用户在白名单中,即使全局禁用+free套餐也启用 + assert flag.is_enabled(user_plan="free", user_id="user_001") is True + + def test_user_override_disable(self): + """用户白名单可单独禁用""" + flag = FeatureFlag( + name="test_feature", + global_enabled=True, + user_overrides={"user_002": False}, + ) + assert flag.is_enabled(user_id="user_002") is False + assert flag.is_enabled(user_id="user_001") is True + + def test_user_override_without_plan(self): + """用户白名单无需套餐也生效""" + flag = FeatureFlag( + name="test_feature", global_enabled=False, user_overrides={"u1": True} + ) + assert flag.is_enabled(user_id="u1") is True + assert flag.is_enabled(user_id="u2") is False + + def test_no_plan_uses_global(self): + """不传 user_plan 时回退到全局开关""" + flag = FeatureFlag(name="test", global_enabled=True, plan_overrides={"free": False}) + assert flag.is_enabled() is True + + def test_default_values(self): + """默认值正确""" + flag = FeatureFlag(name="test") + assert flag.name == "test" + assert flag.description == "" + assert flag.global_enabled is True + assert flag.plan_overrides == {} + assert flag.user_overrides == {} + + +class TestFeatureFlags: + """FeatureFlags 管理器测试""" + + def test_singleton_default_flags(self): + """默认有 5 个 feature flags""" + ff = FeatureFlags() + flags = ff.list_flags() + assert len(flags) == 5 + assert FeatureScope.AI_VOICE_GENERATION in flags + assert FeatureScope.DEDUPLICATION_REPORT in flags + assert FeatureScope.BATCH_EXPORT in flags + assert FeatureScope.MULTI_PLATFORM_OUTPUT in flags + assert FeatureScope.RECIPE_REUSE in flags + + def test_is_enabled_existing_flag(self): + """已存在的 flag 正常判断""" + ff = FeatureFlags() + assert ff.is_enabled(FeatureScope.AI_VOICE_GENERATION) is True + + def test_is_enabled_nonexistent_flag(self): + """不存在的 flag 默认禁用""" + ff = FeatureFlags() + assert ff.is_enabled("nonexistent_flag") is False + + def test_is_enabled_with_plan(self): + """按套餐判断""" + ff = FeatureFlags() + # free 套餐 AI 配音不可用 + assert ff.is_enabled(FeatureScope.AI_VOICE_GENERATION, user_plan="free") is False + assert ff.is_enabled(FeatureScope.AI_VOICE_GENERATION, user_plan="basic") is True + assert ff.is_enabled(FeatureScope.AI_VOICE_GENERATION, user_plan="premium") is True + + def test_is_enabled_premium_only_features(self): + """仅 premium 可用的功能""" + ff = FeatureFlags() + for feat in [FeatureScope.DEDUPLICATION_REPORT, FeatureScope.MULTI_PLATFORM_OUTPUT]: + assert ff.is_enabled(feat, user_plan="free") is False + assert ff.is_enabled(feat, user_plan="basic") is False + assert ff.is_enabled(feat, user_plan="premium") is True + + def test_register_new_flag(self): + """注册新 flag""" + ff = FeatureFlags() + new_flag = FeatureFlag(name="new_feature", description="新功能", global_enabled=True) + ff.register(new_flag) + + assert ff.is_enabled("new_feature") is True + assert ff.get("new_feature") is not None + assert ff.get("new_feature").description == "新功能" + + def test_register_overwrites_existing(self): + """注册同名 flag 覆盖旧的""" + ff = FeatureFlags() + original = ff.get(FeatureScope.AI_VOICE_GENERATION) + assert original.global_enabled is True + + new_flag = FeatureFlag(name=FeatureScope.AI_VOICE_GENERATION, global_enabled=False) + ff.register(new_flag) + + assert ff.is_enabled(FeatureScope.AI_VOICE_GENERATION) is False + + def test_get_returns_none_for_missing(self): + """获取不存在的 flag 返回 None""" + ff = FeatureFlags() + assert ff.get("no_such_flag") is None + + def test_set_global(self): + """设置全局开关""" + ff = FeatureFlags() + ff.set_global(FeatureScope.BATCH_EXPORT, False) + assert ff.is_enabled(FeatureScope.BATCH_EXPORT) is False + + ff.set_global(FeatureScope.BATCH_EXPORT, True) + assert ff.is_enabled(FeatureScope.BATCH_EXPORT) is True + + def test_set_global_missing_raises(self): + """设置不存在的 flag 抛 KeyError""" + ff = FeatureFlags() + with pytest.raises(KeyError): + ff.set_global("nonexistent", True) + + def test_set_plan_override(self): + """设置套餐级别覆盖""" + ff = FeatureFlags() + ff.set_plan_override(FeatureScope.BATCH_EXPORT, "basic", False) + assert ff.is_enabled(FeatureScope.BATCH_EXPORT, user_plan="basic") is False + assert ff.is_enabled(FeatureScope.BATCH_EXPORT, user_plan="premium") is True + + def test_set_plan_override_missing_raises(self): + """设置不存在的 flag 抛 KeyError""" + ff = FeatureFlags() + with pytest.raises(KeyError): + ff.set_plan_override("nonexistent", "free", False) + + def test_set_user_override(self): + """设置用户白名单""" + ff = FeatureFlags() + ff.set_user_override(FeatureScope.DEDUPLICATION_REPORT, "user_42", True) + assert ff.is_enabled( + FeatureScope.DEDUPLICATION_REPORT, user_plan="free", user_id="user_42" + ) is True + + def test_set_user_override_disable(self): + """用户白名单禁用""" + ff = FeatureFlags() + ff.set_user_override(FeatureScope.AI_VOICE_GENERATION, "user_99", False) + assert ff.is_enabled( + FeatureScope.AI_VOICE_GENERATION, user_plan="premium", user_id="user_99" + ) is False + + def test_set_user_override_missing_raises(self): + """设置不存在的 flag 抛 KeyError""" + ff = FeatureFlags() + with pytest.raises(KeyError): + ff.set_user_override("nonexistent", "u1", True) + + def test_list_flags_returns_copy(self): + """list_flags 返回副本,修改不影响内部""" + ff = FeatureFlags() + flags = ff.list_flags() + flags["new_one"] = FeatureFlag(name="new_one") + assert ff.get("new_one") is None + + def test_get_enabled_for_plan_free(self): + """获取 free 套餐下启用的功能""" + ff = FeatureFlags() + enabled = ff.get_enabled_for_plan("free") + # free 套餐只有 recipe_reuse 可用?不对,看看默认配置 + # AI_VOICE_GENERATION: free=False + # DEDUPLICATION_REPORT: free=False, basic=False + # BATCH_EXPORT: free=False + # MULTI_PLATFORM_OUTPUT: free=False, basic=False + # RECIPE_REUSE: free=False + # 所以 free 套餐全部禁用?不,recipe_reuse free=False + # 等等,RECIPE_REUSE 的 plan_overrides 是 {"free": False}, + # 那对于 free 套餐,返回 False;但全局是 True + # 所以 free 套餐没有任何启用的?不对... + # 让我重新看:global_enabled=True,plan_overrides={"free": False} + # 那么 free 套餐 is_enabled 是 False,其他套餐是 True + # 所以 free 套餐应该 0 个启用?不对,等等... + # 不,我需要重新检查每个 flag 的 plan_overrides + # AI_VOICE_GENERATION: free=False → free: False, basic/premium: True + # DEDUPLICATION_REPORT: free=False, basic=False → free/basic: False, premium: True + # BATCH_EXPORT: free=False → free: False, basic/premium: True + # MULTI_PLATFORM_OUTPUT: free=False, basic=False → free/basic: False, premium: True + # RECIPE_REUSE: free=False → free: False, basic/premium: True + assert len(enabled) == 0 + + def test_get_enabled_for_plan_basic(self): + """获取 basic 套餐下启用的功能""" + ff = FeatureFlags() + enabled = ff.get_enabled_for_plan("basic") + # basic 套餐:AI_VOICE、BATCH_EXPORT、RECIPE_REUSE 可用 + # DEDUPLICATION_REPORT、MULTI_PLATFORM_OUTPUT 不可用 + assert FeatureScope.AI_VOICE_GENERATION in enabled + assert FeatureScope.BATCH_EXPORT in enabled + assert FeatureScope.RECIPE_REUSE in enabled + assert FeatureScope.DEDUPLICATION_REPORT not in enabled + assert FeatureScope.MULTI_PLATFORM_OUTPUT not in enabled + assert len(enabled) == 3 + + def test_get_enabled_for_plan_premium(self): + """获取 premium 套餐下所有功能都启用""" + ff = FeatureFlags() + enabled = ff.get_enabled_for_plan("premium") + assert len(enabled) == 5 diff --git a/tests/unit/test_tts_job_use_cases.py b/tests/unit/test_tts_job_use_cases.py new file mode 100755 index 000000000..1c6b8c6ff --- /dev/null +++ b/tests/unit/test_tts_job_use_cases.py @@ -0,0 +1,253 @@ +"""TTS Job Use Cases 单元测试""" + +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock + +from packages.application.tts_job.use_cases import ( + CreateTTSJobUseCase, + ListTTSJobsUseCase, + GetTTSJobUseCase, + GetTTSJobStatusUseCase, + DeleteTTSJobUseCase, +) +from packages.application.tts_job.exceptions import TTSJobNotFoundError +from packages.domain.tts_job import TTSJob, TTSJobStatus + + +@pytest.fixture +def mock_repo(): + return MagicMock() + + +@pytest.fixture +def sample_job(): + return TTSJob.create( + user_id="user_001", + input_text="测试文本", + voice_id="voice_001", + voice_model="cosyvoice", + project_id="proj_001", + sample_rate=22050, + format="mp3", + max_retries=3, + ) + + +class TestCreateTTSJobUseCase: + """创建 TTS 任务用例测试""" + + def test_create_success(self, mock_repo, sample_job): + """创建成功""" + mock_repo.create.return_value = sample_job + + use_case = CreateTTSJobUseCase(mock_repo) + result = use_case.execute( + user_id="user_001", + input_text="测试文本", + voice_id="voice_001", + voice_model="cosyvoice", + project_id="proj_001", + ) + + assert result is not None + assert result.user_id == "user_001" + assert result.input_text == "测试文本" + assert result.status == TTSJobStatus.PENDING + mock_repo.create.assert_called_once() + + def test_create_with_default_params(self, mock_repo): + """使用默认参数创建""" + mock_repo.create.side_effect = lambda x: x + + use_case = CreateTTSJobUseCase(mock_repo) + result = use_case.execute(user_id="user_001", input_text="hello") + + assert result.voice_id == "" + assert result.voice_model == "" + assert result.sample_rate == 22050 + assert result.format == "mp3" + assert result.max_retries == 3 + + def test_create_with_metadata(self, mock_repo): + """创建时携带 metadata""" + mock_repo.create.side_effect = lambda x: x + + use_case = CreateTTSJobUseCase(mock_repo) + result = use_case.execute( + user_id="user_001", + input_text="test", + metadata={"source": "api", "priority": "high"}, + ) + + assert result.metadata["source"] == "api" + assert result.metadata["priority"] == "high" + + def test_create_with_voice_clone_profile(self, mock_repo): + """使用音色克隆档案创建""" + mock_repo.create.side_effect = lambda x: x + + use_case = CreateTTSJobUseCase(mock_repo) + result = use_case.execute( + user_id="user_001", + input_text="test", + voice_clone_profile_id="clone_001", + ) + + assert result.voice_clone_profile_id == "clone_001" + + +class TestListTTSJobsUseCase: + """列出 TTS 任务用例测试""" + + def test_list_success(self, mock_repo, sample_job): + """列出任务成功""" + mock_repo.list_by_user.return_value = [sample_job] + mock_repo.count_by_user.return_value = 1 + + use_case = ListTTSJobsUseCase(mock_repo) + items, total = use_case.execute(user_id="user_001") + + assert len(items) == 1 + assert total == 1 + mock_repo.list_by_user.assert_called_once_with( + "user_001", status=None, limit=50, offset=0 + ) + + def test_list_with_status_filter(self, mock_repo): + """按状态过滤""" + mock_repo.list_by_user.return_value = [] + mock_repo.count_by_user.return_value = 0 + + use_case = ListTTSJobsUseCase(mock_repo) + items, total = use_case.execute(user_id="user_001", status="completed") + + assert total == 0 + mock_repo.list_by_user.assert_called_once_with( + "user_001", status="completed", limit=50, offset=0 + ) + mock_repo.count_by_user.assert_called_once_with( + "user_001", status="completed" + ) + + def test_list_with_pagination(self, mock_repo): + """分页参数正确传递""" + mock_repo.list_by_user.return_value = [] + mock_repo.count_by_user.return_value = 0 + + use_case = ListTTSJobsUseCase(mock_repo) + use_case.execute(user_id="user_001", skip=10, limit=20) + + mock_repo.list_by_user.assert_called_once_with( + "user_001", status=None, limit=20, offset=10 + ) + + def test_list_empty(self, mock_repo): + """空列表""" + mock_repo.list_by_user.return_value = [] + mock_repo.count_by_user.return_value = 0 + + use_case = ListTTSJobsUseCase(mock_repo) + items, total = use_case.execute(user_id="user_001") + + assert items == [] + assert total == 0 + + +class TestGetTTSJobUseCase: + """获取 TTS 任务详情用例测试""" + + def test_get_success(self, mock_repo, sample_job): + """获取成功""" + mock_repo.get.return_value = sample_job + + use_case = GetTTSJobUseCase(mock_repo) + result = use_case.execute(job_id=sample_job.id, user_id="user_001") + + assert result.id == sample_job.id + mock_repo.get.assert_called_once_with(sample_job.id) + + def test_get_not_found(self, mock_repo): + """任务不存在""" + mock_repo.get.return_value = None + + use_case = GetTTSJobUseCase(mock_repo) + + with pytest.raises(TTSJobNotFoundError): + use_case.execute(job_id="nonexistent", user_id="user_001") + + def test_get_wrong_user(self, mock_repo, sample_job): + """用户不匹配""" + mock_repo.get.return_value = sample_job # user_001 + + use_case = GetTTSJobUseCase(mock_repo) + + with pytest.raises(TTSJobNotFoundError): + use_case.execute(job_id=sample_job.id, user_id="other_user") + + +class TestGetTTSJobStatusUseCase: + """查询 TTS 任务状态用例测试""" + + def test_get_status_success(self, mock_repo, sample_job): + """获取状态成功""" + mock_repo.get.return_value = sample_job + + use_case = GetTTSJobStatusUseCase(mock_repo) + result = use_case.execute(job_id=sample_job.id, user_id="user_001") + + assert result.status == TTSJobStatus.PENDING + + def test_get_status_not_found(self, mock_repo): + """任务不存在抛异常""" + mock_repo.get.return_value = None + + use_case = GetTTSJobStatusUseCase(mock_repo) + + with pytest.raises(TTSJobNotFoundError): + use_case.execute(job_id="nonexistent", user_id="user_001") + + def test_get_status_wrong_user(self, mock_repo, sample_job): + """用户不匹配抛异常""" + mock_repo.get.return_value = sample_job + + use_case = GetTTSJobStatusUseCase(mock_repo) + + with pytest.raises(TTSJobNotFoundError): + use_case.execute(job_id=sample_job.id, user_id="other_user") + + +class TestDeleteTTSJobUseCase: + """删除 TTS 任务用例测试""" + + def test_delete_success(self, mock_repo, sample_job): + """删除成功""" + mock_repo.get.return_value = sample_job + mock_repo.delete.return_value = True + + use_case = DeleteTTSJobUseCase(mock_repo) + result = use_case.execute(job_id=sample_job.id, user_id="user_001") + + assert result is True + mock_repo.delete.assert_called_once_with(sample_job.id) + + def test_delete_not_found(self, mock_repo): + """任务不存在返回 False""" + mock_repo.get.return_value = None + + use_case = DeleteTTSJobUseCase(mock_repo) + result = use_case.execute(job_id="nonexistent", user_id="user_001") + + assert result is False + mock_repo.delete.assert_not_called() + + def test_delete_wrong_user(self, mock_repo, sample_job): + """用户不匹配返回 False""" + mock_repo.get.return_value = sample_job + + use_case = DeleteTTSJobUseCase(mock_repo) + result = use_case.execute(job_id=sample_job.id, user_id="other_user") + + assert result is False + mock_repo.delete.assert_not_called() diff --git a/tests/unit/test_voice_clone_use_cases.py b/tests/unit/test_voice_clone_use_cases.py new file mode 100755 index 000000000..736929bb7 --- /dev/null +++ b/tests/unit/test_voice_clone_use_cases.py @@ -0,0 +1,328 @@ +"""Voice Clone Use Cases 单元测试""" + +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock + +from packages.application.voice_clone.use_cases import ( + CreateVoiceCloneUseCase, + ListVoiceClonesUseCase, + GetVoiceCloneUseCase, + GetVoiceCloneStatusUseCase, + DeleteVoiceCloneUseCase, + RetryVoiceCloneUseCase, + VoiceCloneNotFoundError, + VoiceCloneNotRetryableError, +) +from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus + + +@pytest.fixture +def mock_repo(): + return MagicMock() + + +@pytest.fixture +def sample_profile(): + return VoiceCloneProfile.create( + user_id="user_001", + name="我的音色", + description="测试音色克隆", + source_audio_url="https://example.com/audio.wav", + voice_model="cosyvoice", + language="zh-CN", + gender="female", + max_retries=3, + ) + + +class TestCreateVoiceCloneUseCase: + """创建音色克隆用例测试""" + + def test_create_success(self, mock_repo, sample_profile): + """创建成功""" + mock_repo.create.return_value = sample_profile + + use_case = CreateVoiceCloneUseCase(mock_repo) + result = use_case.execute( + user_id="user_001", + name="我的音色", + source_audio_url="https://example.com/audio.wav", + ) + + assert result is not None + assert result.user_id == "user_001" + assert result.name == "我的音色" + assert result.status == VoiceCloneStatus.PENDING + mock_repo.create.assert_called_once() + + def test_create_with_default_params(self, mock_repo): + """使用默认参数创建""" + mock_repo.create.side_effect = lambda x: x + + use_case = CreateVoiceCloneUseCase(mock_repo) + result = use_case.execute(user_id="user_001", name="测试音色") + + assert result.description == "" + assert result.source_audio_url == "" + assert result.voice_model == "" + assert result.language == "zh-CN" + assert result.gender == "unknown" + assert result.max_retries == 3 + + def test_create_with_metadata(self, mock_repo): + """创建时携带 metadata""" + mock_repo.create.side_effect = lambda x: x + + use_case = CreateVoiceCloneUseCase(mock_repo) + result = use_case.execute( + user_id="user_001", + name="test", + metadata={"source": "upload", "duration": 10}, + ) + + assert result.metadata["source"] == "upload" + assert result.metadata["duration"] == 10 + + def test_create_custom_max_retries(self, mock_repo): + """自定义重试次数""" + mock_repo.create.side_effect = lambda x: x + + use_case = CreateVoiceCloneUseCase(mock_repo) + result = use_case.execute( + user_id="user_001", name="test", max_retries=5 + ) + + assert result.max_retries == 5 + + +class TestListVoiceClonesUseCase: + """列出音色克隆用例测试""" + + def test_list_success(self, mock_repo, sample_profile): + """列出成功""" + mock_repo.list_by_user.return_value = [sample_profile] + mock_repo.count_by_user.return_value = 1 + + use_case = ListVoiceClonesUseCase(mock_repo) + items, total = use_case.execute(user_id="user_001") + + assert len(items) == 1 + assert total == 1 + mock_repo.list_by_user.assert_called_once_with( + "user_001", status=None, limit=50, offset=0 + ) + + def test_list_with_status_filter(self, mock_repo): + """按状态过滤""" + mock_repo.list_by_user.return_value = [] + mock_repo.count_by_user.return_value = 0 + + use_case = ListVoiceClonesUseCase(mock_repo) + items, total = use_case.execute(user_id="user_001", status="completed") + + assert total == 0 + mock_repo.list_by_user.assert_called_once_with( + "user_001", status="completed", limit=50, offset=0 + ) + + def test_list_with_pagination(self, mock_repo): + """分页参数正确传递""" + mock_repo.list_by_user.return_value = [] + mock_repo.count_by_user.return_value = 0 + + use_case = ListVoiceClonesUseCase(mock_repo) + use_case.execute(user_id="user_001", skip=20, limit=10) + + mock_repo.list_by_user.assert_called_once_with( + "user_001", status=None, limit=10, offset=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 = ListVoiceClonesUseCase(mock_repo) + items, total = use_case.execute(user_id="user_001") + + assert items == [] + assert total == 0 + + +class TestGetVoiceCloneUseCase: + """获取音色克隆详情用例测试""" + + def test_get_success(self, mock_repo, sample_profile): + """获取成功""" + mock_repo.get.return_value = sample_profile + + use_case = GetVoiceCloneUseCase(mock_repo) + result = use_case.execute(clone_id=sample_profile.id, user_id="user_001") + + assert result.id == sample_profile.id + mock_repo.get.assert_called_once_with(sample_profile.id) + + def test_get_not_found(self, mock_repo): + """不存在抛异常""" + mock_repo.get.return_value = None + + use_case = GetVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotFoundError): + use_case.execute(clone_id="nonexistent", user_id="user_001") + + def test_get_wrong_user(self, mock_repo, sample_profile): + """用户不匹配抛异常""" + mock_repo.get.return_value = sample_profile + + use_case = GetVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotFoundError): + use_case.execute(clone_id=sample_profile.id, user_id="other_user") + + +class TestGetVoiceCloneStatusUseCase: + """查询音色克隆状态用例测试""" + + def test_get_status_success(self, mock_repo, sample_profile): + """获取状态成功""" + mock_repo.get.return_value = sample_profile + + use_case = GetVoiceCloneStatusUseCase(mock_repo) + result = use_case.execute(clone_id=sample_profile.id, user_id="user_001") + + assert result.status == VoiceCloneStatus.PENDING + + def test_get_status_not_found(self, mock_repo): + """不存在抛异常""" + mock_repo.get.return_value = None + + use_case = GetVoiceCloneStatusUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotFoundError): + use_case.execute(clone_id="nonexistent", user_id="user_001") + + def test_get_status_wrong_user(self, mock_repo, sample_profile): + """用户不匹配抛异常""" + mock_repo.get.return_value = sample_profile + + use_case = GetVoiceCloneStatusUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotFoundError): + use_case.execute(clone_id=sample_profile.id, user_id="other_user") + + +class TestDeleteVoiceCloneUseCase: + """删除音色克隆用例测试""" + + def test_delete_success(self, mock_repo, sample_profile): + """删除成功""" + mock_repo.get.return_value = sample_profile + mock_repo.delete.return_value = True + + use_case = DeleteVoiceCloneUseCase(mock_repo) + result = use_case.execute(clone_id=sample_profile.id, user_id="user_001") + + assert result is True + mock_repo.delete.assert_called_once_with(sample_profile.id) + + def test_delete_not_found(self, mock_repo): + """不存在返回 False""" + mock_repo.get.return_value = None + + use_case = DeleteVoiceCloneUseCase(mock_repo) + result = use_case.execute(clone_id="nonexistent", user_id="user_001") + + assert result is False + mock_repo.delete.assert_not_called() + + def test_delete_wrong_user(self, mock_repo, sample_profile): + """用户不匹配返回 False""" + mock_repo.get.return_value = sample_profile + + use_case = DeleteVoiceCloneUseCase(mock_repo) + result = use_case.execute(clone_id=sample_profile.id, user_id="other_user") + + assert result is False + mock_repo.delete.assert_not_called() + + +class TestRetryVoiceCloneUseCase: + """重试音色克隆用例测试""" + + def test_retry_success(self, mock_repo, sample_profile): + """失败状态重试成功""" + sample_profile.status = VoiceCloneStatus.FAILED + sample_profile.retry_count = 1 + mock_repo.get.return_value = sample_profile + mock_repo.update.side_effect = lambda x: x + + use_case = RetryVoiceCloneUseCase(mock_repo) + result = use_case.execute(clone_id=sample_profile.id, user_id="user_001") + + assert result.status == VoiceCloneStatus.PENDING + assert result.retry_count == 2 + mock_repo.update.assert_called_once() + + def test_retry_not_found(self, mock_repo): + """不存在抛异常""" + mock_repo.get.return_value = None + + use_case = RetryVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotFoundError): + use_case.execute(clone_id="nonexistent", user_id="user_001") + + def test_retry_wrong_user(self, mock_repo, sample_profile): + """用户不匹配抛异常""" + sample_profile.status = VoiceCloneStatus.FAILED + mock_repo.get.return_value = sample_profile + + use_case = RetryVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotFoundError): + use_case.execute(clone_id=sample_profile.id, user_id="other_user") + + def test_retry_not_retryable_pending(self, mock_repo, sample_profile): + """pending 状态不可重试""" + sample_profile.status = VoiceCloneStatus.PENDING + mock_repo.get.return_value = sample_profile + + use_case = RetryVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotRetryableError): + use_case.execute(clone_id=sample_profile.id, user_id="user_001") + + def test_retry_not_retryable_processing(self, mock_repo, sample_profile): + """processing 状态不可重试""" + sample_profile.status = VoiceCloneStatus.PROCESSING + mock_repo.get.return_value = sample_profile + + use_case = RetryVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotRetryableError): + use_case.execute(clone_id=sample_profile.id, user_id="user_001") + + def test_retry_not_retryable_ready(self, mock_repo, sample_profile): + """ready 状态不可重试""" + sample_profile.status = VoiceCloneStatus.READY + mock_repo.get.return_value = sample_profile + + use_case = RetryVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotRetryableError): + use_case.execute(clone_id=sample_profile.id, user_id="user_001") + + def test_retry_max_retries_exceeded(self, mock_repo, sample_profile): + """超过重试上限不可重试""" + sample_profile.status = VoiceCloneStatus.FAILED + sample_profile.retry_count = 3 + sample_profile.max_retries = 3 + mock_repo.get.return_value = sample_profile + + use_case = RetryVoiceCloneUseCase(mock_repo) + + with pytest.raises(VoiceCloneNotRetryableError): + use_case.execute(clone_id=sample_profile.id, user_id="user_001")