Files
xiaoxia-saas/tests/unit/test_recipe_use_cases.py
CI Bot 3921a657e8
CI/CD Pipeline / Check if frontend-only change (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 Lint (push) Successful in 52s
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 / Validate - Type Check (mypy) (push) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m27s
CI/CD Pipeline / Unit Tests (push) Failing after 8m24s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m1s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
style: auto-format with black + isort + prettier
2026-07-24 08:47:07 +00:00

402 lines
14 KiB
Python
Executable File

"""配方 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)