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
402 lines
14 KiB
Python
Executable File
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)
|