"""配方 Recipe UseCase 单元测试.""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from packages.application.recipe.commands import ( CreateRecipeCommand, RecipeItemCommand, UpdateRecipeCommand, ) from packages.application.recipe.use_cases import ( CreateRecipeUseCase, DeleteRecipeUseCase, FeatureDisabledError, GetRecipeUseCase, ListRecipesUseCase, UpdateRecipeUseCase, UseRecipeResult, UseRecipeUseCase, ) from packages.domain.exceptions import NotFoundError from packages.domain.recipe import Recipe, RecipeItem def _make_recipe(id: str, name: str, user_id: str = "user_1", item_count: int = 0) -> Recipe: items = [ RecipeItem( id=f"item_{i}", recipe_id=id, item_type="asset", item_id=f"asset_{i}", position=i, ) for i in range(item_count) ] return Recipe( id=id, user_id=user_id, name=name, description="测试配方", template_id="tmpl_1", generation_params={"resolution": "1080p"}, items=items, is_active=True, metadata_={}, ) @pytest.fixture def mock_repo(): return MagicMock() class TestListRecipesUseCase: """ListRecipesUseCase 测试""" def test_list_returns_results(self, mock_repo): """正常返回配方列表""" recipe = _make_recipe("r1", "配方1") mock_repo.list_by_user.return_value = [recipe] use_case = ListRecipesUseCase(mock_repo) result = use_case.execute("user_1") assert len(result) == 1 assert result[0].id == "r1" mock_repo.list_by_user.assert_called_once_with("user_1", skip=0, limit=50) def test_list_with_pagination(self, mock_repo): """带分页参数""" mock_repo.list_by_user.return_value = [] use_case = ListRecipesUseCase(mock_repo) use_case.execute("user_1", skip=5, limit=10) mock_repo.list_by_user.assert_called_once_with("user_1", skip=5, limit=10) def test_empty_list(self, mock_repo): """空列表""" mock_repo.list_by_user.return_value = [] use_case = ListRecipesUseCase(mock_repo) result = use_case.execute("user_1") assert result == [] class TestGetRecipeUseCase: """GetRecipeUseCase 测试""" def test_get_existing(self, mock_repo): """获取存在的配方""" recipe = _make_recipe("r1", "配方1", item_count=3) mock_repo.get.return_value = recipe use_case = GetRecipeUseCase(mock_repo) result = use_case.execute("r1", "user_1") assert result is not None assert result.id == "r1" assert len(result.items) == 3 mock_repo.get.assert_called_once_with("r1", "user_1") def test_get_nonexistent_returns_none(self, mock_repo): """获取不存在的配方返回 None""" mock_repo.get.return_value = None use_case = GetRecipeUseCase(mock_repo) result = use_case.execute("noexist", "user_1") assert result is None class TestCreateRecipeUseCase: """CreateRecipeUseCase 测试""" def test_create_without_items(self, mock_repo): """创建不带items的配方""" mock_repo.create.side_effect = lambda x: x use_case = CreateRecipeUseCase(mock_repo) command = CreateRecipeCommand( user_id="user_1", name="新配方", description="测试", template_id="tmpl_1", generation_params={"key": "value"}, items=[], ) result = use_case.execute(command) assert isinstance(result, Recipe) assert result.name == "新配方" assert result.user_id == "user_1" assert result.template_id == "tmpl_1" assert result.generation_params == {"key": "value"} assert result.items == [] mock_repo.create.assert_called_once() mock_repo.create_items.assert_not_called() def test_create_with_items(self, mock_repo): """创建带items的配方""" mock_repo.create.side_effect = lambda x: x mock_repo.create_items.side_effect = lambda items: items use_case = CreateRecipeUseCase(mock_repo) command = CreateRecipeCommand( user_id="user_1", name="带素材配方", items=[ RecipeItemCommand(item_type="asset", item_id="a1", position=0), RecipeItemCommand(item_type="title", item_id="t1", position=1), RecipeItemCommand(item_type="voice", item_id="v1", position=2), ], ) result = use_case.execute(command) assert len(result.items) == 3 assert result.items[0].item_type == "asset" assert result.items[1].item_type == "title" assert result.items[2].item_type == "voice" mock_repo.create.assert_called_once() mock_repo.create_items.assert_called_once() created_items = mock_repo.create_items.call_args[0][0] assert len(created_items) == 3 def test_create_with_default_values(self, mock_repo): """使用默认值创建""" mock_repo.create.side_effect = lambda x: x use_case = CreateRecipeUseCase(mock_repo) command = CreateRecipeCommand(user_id="user_1", name="极简配方") result = use_case.execute(command) assert result.description == "" assert result.template_id == "" assert result.generation_params == {} assert result.items == [] assert result.metadata_ == {} class TestUpdateRecipeUseCase: """UpdateRecipeUseCase 测试""" def test_update_name(self, mock_repo): """更新配方名称""" recipe = _make_recipe("r1", "旧名称") mock_repo.get.return_value = recipe mock_repo.update.side_effect = lambda x: x mock_repo.list_items.return_value = [] use_case = UpdateRecipeUseCase(mock_repo) command = UpdateRecipeCommand(recipe_id="r1", user_id="user_1", name="新名称") result = use_case.execute(command) assert result.name == "新名称" # 其他不变 assert result.description == "测试配方" assert result.template_id == "tmpl_1" mock_repo.get.assert_called_once_with("r1", "user_1") mock_repo.update.assert_called_once() # 没传items时从repository加载 mock_repo.list_items.assert_called_once_with("r1") def test_update_multiple_fields(self, mock_repo): """同时更新多个字段""" recipe = _make_recipe("r1", "旧") mock_repo.get.return_value = recipe mock_repo.update.side_effect = lambda x: x mock_repo.list_items.return_value = [] use_case = UpdateRecipeUseCase(mock_repo) command = UpdateRecipeCommand( recipe_id="r1", user_id="user_1", description="新描述", template_id="tmpl_new", generation_params={"new": "params"}, ) result = use_case.execute(command) assert result.description == "新描述" assert result.template_id == "tmpl_new" assert result.generation_params == {"new": "params"} def test_update_items_replaces_old(self, mock_repo): """更新items时删除旧的并创建新的""" recipe = _make_recipe("r1", "配方", item_count=2) mock_repo.get.return_value = recipe mock_repo.update.side_effect = lambda x: x mock_repo.create_items.side_effect = lambda items: items use_case = UpdateRecipeUseCase(mock_repo) command = UpdateRecipeCommand( recipe_id="r1", user_id="user_1", items=[ RecipeItemCommand(item_type="asset", item_id="new_a", position=0), RecipeItemCommand(item_type="title", item_id="new_t", position=1), ], ) result = use_case.execute(command) mock_repo.delete_items_by_recipe.assert_called_once_with("r1") mock_repo.create_items.assert_called_once() assert len(result.items) == 2 assert result.items[0].item_id == "new_a" def test_update_empty_items_list(self, mock_repo): """更新为空items列表也会替换""" recipe = _make_recipe("r1", "配方", item_count=3) mock_repo.get.return_value = recipe mock_repo.update.side_effect = lambda x: x mock_repo.create_items.return_value = [] use_case = UpdateRecipeUseCase(mock_repo) command = UpdateRecipeCommand(recipe_id="r1", user_id="user_1", items=[]) result = use_case.execute(command) mock_repo.delete_items_by_recipe.assert_called_once() mock_repo.create_items.assert_called_once_with([]) assert result.items == [] def test_update_nonexistent_raises(self, mock_repo): """更新不存在的配方抛出 NotFoundError""" mock_repo.get.return_value = None use_case = UpdateRecipeUseCase(mock_repo) command = UpdateRecipeCommand(recipe_id="noexist", user_id="user_1", name="新名称") with pytest.raises(NotFoundError, match="not found"): use_case.execute(command) mock_repo.update.assert_not_called() class TestDeleteRecipeUseCase: """DeleteRecipeUseCase 测试""" def test_delete_success(self, mock_repo): """删除成功""" mock_repo.delete.return_value = True use_case = DeleteRecipeUseCase(mock_repo) result = use_case.execute("r1", "user_1") assert result is True mock_repo.delete.assert_called_once_with("r1", "user_1") def test_delete_nonexistent_returns_false(self, mock_repo): """删除不存在的返回 False""" mock_repo.delete.return_value = False use_case = DeleteRecipeUseCase(mock_repo) result = use_case.execute("noexist", "user_1") assert result is False class TestUseRecipeUseCase: """UseRecipeUseCase 使用配方测试""" def test_use_recipe_premium_enabled(self, mock_repo): """premium用户可以使用配方""" recipe = _make_recipe("r1", "配方1", item_count=2) mock_repo.get.return_value = recipe use_case = UseRecipeUseCase(mock_repo) # 用 patch mock feature_flags with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff: mock_ff.is_enabled.return_value = True result = use_case.execute("r1", "user_1", user_plan="premium") assert isinstance(result, UseRecipeResult) assert result.recipe.id == "r1" assert isinstance(result.warnings, list) mock_repo.get.assert_called_once_with("r1", "user_1") def test_use_recipe_basic_enabled(self, mock_repo): """basic用户可以使用配方""" recipe = _make_recipe("r1", "配方1") mock_repo.get.return_value = recipe use_case = UseRecipeUseCase(mock_repo) with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff: mock_ff.is_enabled.return_value = True result = use_case.execute("r1", "user_1", user_plan="basic") assert result.recipe.id == "r1" def test_use_recipe_feature_disabled(self, mock_repo): """功能未启用时抛出 FeatureDisabledError""" use_case = UseRecipeUseCase(mock_repo) with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff: mock_ff.is_enabled.return_value = False with pytest.raises(FeatureDisabledError, match="仅对基础版和高级版"): use_case.execute("r1", "user_1", user_plan="free") mock_repo.get.assert_not_called() def test_use_recipe_not_found(self, mock_repo): """配方不存在时抛出 NotFoundError""" mock_repo.get.return_value = None use_case = UseRecipeUseCase(mock_repo) with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff: mock_ff.is_enabled.return_value = True with pytest.raises(NotFoundError, match="not found"): use_case.execute("noexist", "user_1", user_plan="premium") class TestRecipeCommands: """命令数据类测试""" def test_create_recipe_command_fields(self): """CreateRecipeCommand 字段""" cmd = CreateRecipeCommand( user_id="u1", name="测试", description="desc", template_id="t1", generation_params={"a": 1}, items=[RecipeItemCommand(item_type="asset", item_id="a1", position=0)], metadata_={"key": "val"}, ) assert cmd.user_id == "u1" assert cmd.name == "测试" assert cmd.description == "desc" assert cmd.template_id == "t1" assert cmd.generation_params == {"a": 1} assert len(cmd.items) == 1 assert cmd.items[0].item_type == "asset" assert cmd.metadata_ == {"key": "val"} def test_recipe_item_command_defaults(self): """RecipeItemCommand 默认值""" cmd = RecipeItemCommand(item_type="asset", item_id="a1") assert cmd.position == 0 assert cmd.metadata_ == {} def test_update_recipe_command_defaults_none(self): """UpdateRecipeCommand 字段默认None""" cmd = UpdateRecipeCommand(recipe_id="r1", user_id="u1") assert cmd.name is None assert cmd.description is None assert cmd.template_id is None assert cmd.generation_params is None assert cmd.items is None assert cmd.metadata_ is None def test_commands_are_dataclasses(self): """都是 dataclass""" from dataclasses import is_dataclass assert is_dataclass(CreateRecipeCommand) assert is_dataclass(UpdateRecipeCommand) assert is_dataclass(RecipeItemCommand) assert is_dataclass(UseRecipeResult)