test: P3-1 第34波单元测试(title_library/recipe) #812

Merged
xiaoxia merged 1 commits from test/unit-test-wave34 into develop 2026-07-24 16:44:33 +08:00
2 changed files with 697 additions and 563 deletions
+338 -144
View File
@@ -1,9 +1,8 @@
"""Recipe use cases unit tests."""
"""配方 Recipe UseCase 单元测试."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import Mock
from unittest.mock import MagicMock, patch
import pytest
@@ -18,70 +17,138 @@ from packages.application.recipe.use_cases import (
FeatureDisabledError,
GetRecipeUseCase,
ListRecipesUseCase,
NotFoundError,
UpdateRecipeUseCase,
UseRecipeResult,
UseRecipeUseCase,
)
from packages.domain.exceptions import NotFoundError
from packages.domain.recipe import Recipe, RecipeItem
def _make_recipe(**kwargs) -> Recipe:
defaults = dict(
id="recipe001",
user_id="user001",
name="测试配方",
description="描述",
template_id="tpl001",
generation_params={"mode": "one_take"},
items=[],
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_={},
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
defaults.update(kwargs)
return Recipe(**defaults)
def _make_item(**kwargs) -> RecipeItem:
defaults = dict(
id="item001",
recipe_id="recipe001",
item_type="asset",
item_id="asset001",
position=0,
metadata_={},
)
defaults.update(kwargs)
return RecipeItem(**defaults)
@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:
@pytest.fixture
def mock_repo(self):
repo = Mock()
repo.create = Mock(side_effect=lambda r: r)
repo.create_items = Mock(side_effect=lambda items: items)
return repo
"""CreateRecipeUseCase 测试"""
def test_create_basic(self, mock_repo):
uc = CreateRecipeUseCase(mock_repo)
cmd = CreateRecipeCommand(
user_id="user001",
name="我的配方",
description="desc",
template_id="tpl001",
generation_params={"mode": "one_take"},
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 = uc.execute(cmd)
assert result.name == "我的配方"
assert result.user_id == "user001"
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):
uc = CreateRecipeUseCase(mock_repo)
cmd = CreateRecipeCommand(
user_id="user001",
"""创建带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),
@@ -89,123 +156,250 @@ class TestCreateRecipeUseCase:
RecipeItemCommand(item_type="voice", item_id="v1", position=2),
],
)
result = uc.execute(cmd)
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()
items_arg = mock_repo.create_items.call_args[0][0]
assert items_arg[0].item_type == "asset"
assert items_arg[1].item_type == "title"
assert items_arg[2].item_type == "voice"
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)
class TestListRecipesUseCase:
def test_list(self):
repo = Mock()
repo.list_by_user = Mock(return_value=[_make_recipe()])
uc = ListRecipesUseCase(repo)
result = uc.execute("user001", skip=0, limit=10)
assert len(result) == 1
repo.list_by_user.assert_called_once_with("user001", skip=0, limit=10)
command = CreateRecipeCommand(user_id="user_1", name="极简配方")
result = use_case.execute(command)
class TestGetRecipeUseCase:
def test_get_found(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
uc = GetRecipeUseCase(repo)
result = uc.execute("recipe001", "user001")
assert result is not None
assert result.id == "recipe001"
def test_get_not_found(self):
repo = Mock()
repo.get = Mock(return_value=None)
uc = GetRecipeUseCase(repo)
result = uc.execute("recipe999", "user001")
assert result is None
assert result.description == ""
assert result.template_id == ""
assert result.generation_params == {}
assert result.items == []
assert result.metadata_ == {}
class TestUpdateRecipeUseCase:
@pytest.fixture
def mock_repo(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
repo.update = Mock(side_effect=lambda r: r)
repo.list_items = Mock(return_value=[])
repo.delete_items_by_recipe = Mock(return_value=0)
repo.create_items = Mock(side_effect=lambda items: items)
return repo
"""UpdateRecipeUseCase 测试"""
def test_update_name(self, mock_repo):
uc = UpdateRecipeUseCase(mock_repo)
cmd = UpdateRecipeCommand(
recipe_id="recipe001",
user_id="user001",
name="新名字",
)
result = uc.execute(cmd)
assert result.name == "新名字"
"""更新配方名称"""
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)
def test_update_not_found(self):
repo = Mock()
repo.get = Mock(return_value=None)
uc = UpdateRecipeUseCase(repo)
cmd = UpdateRecipeCommand(recipe_id="xxx", user_id="user001", name="x")
with pytest.raises(NotFoundError):
uc.execute(cmd)
command = UpdateRecipeCommand(recipe_id="r1", user_id="user_1", name="新名称")
result = use_case.execute(command)
def test_update_replace_items(self, mock_repo):
uc = UpdateRecipeUseCase(mock_repo)
cmd = UpdateRecipeCommand(
recipe_id="recipe001",
user_id="user001",
items=[RecipeItemCommand(item_type="voice", item_id="v2", position=0)],
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 = uc.execute(cmd)
mock_repo.delete_items_by_recipe.assert_called_once_with("recipe001")
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) == 1
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:
def test_delete_success(self):
repo = Mock()
repo.delete = Mock(return_value=True)
uc = DeleteRecipeUseCase(repo)
assert uc.execute("recipe001", "user001") is True
"""DeleteRecipeUseCase 测试"""
def test_delete_not_found(self):
repo = Mock()
repo.delete = Mock(return_value=False)
uc = DeleteRecipeUseCase(repo)
assert uc.execute("recipe999", "user001") is False
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:
def test_use_success_basic_plan(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
uc = UseRecipeUseCase(repo)
result = uc.execute("recipe001", "user001", user_plan="basic")
assert result.recipe.id == "recipe001"
assert result.warnings == []
"""UseRecipeUseCase 使用配方测试"""
def test_use_success_premium_plan(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
uc = UseRecipeUseCase(repo)
result = uc.execute("recipe001", "user001", user_plan="premium")
assert result.recipe.id == "recipe001"
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)
def test_use_free_plan_forbidden(self):
repo = Mock()
uc = UseRecipeUseCase(repo)
with pytest.raises(FeatureDisabledError):
uc.execute("recipe001", "user001", user_plan="free")
# 用 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")
def test_use_not_found(self):
repo = Mock()
repo.get = Mock(return_value=None)
uc = UseRecipeUseCase(repo)
with pytest.raises(NotFoundError):
uc.execute("recipe999", "user001", user_plan="basic")
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)
+359 -419
View File
@@ -1,492 +1,432 @@
"""
标题库(Title LibraryUse Case 回归测试
"""标题库 UseCase 单元测试."""
测试目标:
1. CreateTitleLibraryUseCase - 创建标题库条目
2. UpdateTitleLibraryUseCase - 更新标题库条目
3. 配额逻辑覆盖 - titles: free=50, basic=500, premium=500
4. 边界条件与异常场景
"""
from __future__ import annotations
from unittest.mock import Mock
from unittest.mock import MagicMock, patch
import pytest
from packages.application.title_library.commands import (
CreateTitleLibraryCommand,
IncrementTitleUsageCommand,
PickTitleCommand,
UpdateTitleLibraryCommand,
)
from packages.application.title_library.use_cases import (
CreateTitleLibraryUseCase,
DeleteTitleLibraryUseCase,
GetTitleLibraryUseCase,
IncrementTitleUsageUseCase,
ListTitleLibraryUseCase,
NotFoundError,
QuotaExceededError,
PickTitleUseCase,
UpdateTitleLibraryUseCase,
)
from packages.domain.exceptions import NotFoundError, QuotaExceededError
from packages.domain.title_library import TitleLibraryItem
# ---------------------------------------------------------------------------
# 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 CreateTitleLibraryUseCase(repository=mock_repo)
@pytest.fixture
def update_use_case(mock_repo):
return UpdateTitleLibraryUseCase(repository=mock_repo)
@pytest.fixture
def sample_create_command():
"""标准创建命令"""
return CreateTitleLibraryCommand(
user_id="user-001",
name="测试标题",
text="这是一个测试标题文本",
category="新闻",
description="用于测试的标题",
tags=["测试", "新闻"],
metadata_={"source": "unit_test"},
)
@pytest.fixture
def existing_title_item():
"""模拟已存在的标题条目"""
def _make_item(id: str, name: str, text: str, usage_count: int = 0, category: str = "default") -> TitleLibraryItem:
return TitleLibraryItem(
id="existing-title-001",
user_id="user-001",
name="旧标题",
text="旧文本",
category="旧分类",
description="旧描述",
tags=[""],
id=id,
user_id="user_1",
name=name,
text=text,
category=category,
description="",
tags=[],
usage_count=usage_count,
is_active=True,
metadata_={},
)
# ===========================================================================
# 1. CreateTitleLibraryUseCase 测试
# ===========================================================================
@pytest.fixture
def mock_repo():
return MagicMock()
class TestCreateTitleLibraryUseCase:
"""标题库创建 UseCase 测试"""
@pytest.fixture
def sample_item():
return _make_item("title_1", "爆款标题", "这是一个爆款标题文案", usage_count=5)
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.category == "新闻"
assert result.description == "用于测试的标题"
assert result.tags == ["测试", "新闻"]
assert result.metadata_ == {"source": "unit_test"}
class TestListTitleLibraryUseCase:
"""ListTitleLibraryUseCase 测试"""
mock_repo.count_by_user.assert_called_once_with("user-001")
mock_repo.create.assert_called_once()
def test_list_returns_results(self, mock_repo, sample_item):
"""正常返回标题列表"""
mock_repo.list_by_user.return_value = [sample_item]
use_case = ListTitleLibraryUseCase(mock_repo)
def test_create_generates_uuid(self, create_use_case, mock_repo, sample_create_command):
"""测试创建时自动生成 UUID 作为 id"""
result = create_use_case.execute(sample_create_command, plan_name="free")
result = use_case.execute("user_1")
assert result.id is not None
assert len(result.id) == 32 # uuid4().hex 长度为 32
assert result.id.isalnum()
def test_create_default_values(self, create_use_case, mock_repo):
"""测试默认值填充"""
command = CreateTitleLibraryCommand(
user_id="user-001",
name="最小化创建",
text="文本",
assert len(result) == 1
assert result[0].id == "title_1"
mock_repo.list_by_user.assert_called_once_with(
"user_1", category=None, skip=0, limit=50
)
result = create_use_case.execute(command, plan_name="free")
def test_list_with_category(self, mock_repo, sample_item):
"""按分类过滤"""
mock_repo.list_by_user.return_value = [sample_item]
use_case = ListTitleLibraryUseCase(mock_repo)
assert result.category == "default"
assert result.description == ""
assert result.tags == []
assert result.metadata_ == {}
use_case.execute("user_1", category="电商")
# ===========================================================================
# 2. 配额逻辑测试(titles: free=50, basic=500, premium=500
# ===========================================================================
class TestCreateTitleLibraryQuota:
"""标题库创建配额检查测试"""
def test_quota_free_plan_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""free 套餐(上限50),当前 25 个,允许创建"""
mock_repo.count_by_user.return_value = 25
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 套餐(上限50),当前 50 个,拒绝创建"""
mock_repo.count_by_user.return_value = 50
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="free")
assert exc_info.value.dimension == "max_titles"
assert exc_info.value.limit == 50
assert exc_info.value.used == 50
mock_repo.create.assert_not_called()
def test_quota_free_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""free 套餐(上限50),当前 49 个,允许创建(边界)"""
mock_repo.count_by_user.return_value = 49
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_over_limit(self, create_use_case, mock_repo, sample_create_command):
"""free 套餐(上限50),当前 60 个,拒绝创建"""
mock_repo.count_by_user.return_value = 60
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="free")
assert exc_info.value.dimension == "max_titles"
assert exc_info.value.limit == 50
assert exc_info.value.used == 60
def test_quota_basic_plan_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""basic 套餐(上限500),当前 200 个,允许创建"""
mock_repo.count_by_user.return_value = 200
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 套餐(上限500),当前 500 个,拒绝创建"""
mock_repo.count_by_user.return_value = 500
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="basic")
assert exc_info.value.dimension == "max_titles"
assert exc_info.value.limit == 500
assert exc_info.value.used == 500
def test_quota_basic_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""basic 套餐(上限500),当前 499 个,允许创建(边界)"""
mock_repo.count_by_user.return_value = 499
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 套餐(上限500),当前 250 个,允许创建"""
mock_repo.count_by_user.return_value = 250
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 套餐(上限500),当前 500 个,拒绝创建"""
mock_repo.count_by_user.return_value = 500
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="premium")
assert exc_info.value.dimension == "max_titles"
assert exc_info.value.limit == 500
assert exc_info.value.used == 500
def test_quota_premium_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""premium 套餐(上限500),当前 499 个,允许创建(边界)"""
mock_repo.count_by_user.return_value = 499
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_all_plans(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,无法创建"""
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 = 50
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_titles" in str(err)
assert "50" in str(err)
# ===========================================================================
# 3. UpdateTitleLibraryUseCase 测试
# ===========================================================================
class TestUpdateTitleLibraryUseCase:
"""标题库更新 UseCase 测试"""
def test_update_success_all_fields(self, update_use_case, mock_repo, existing_title_item):
"""测试全字段更新成功"""
mock_repo.get.return_value = existing_title_item
command = UpdateTitleLibraryCommand(
title_id="existing-title-001",
user_id="user-001",
name="更新后标题",
text="更新后文本",
category="新分类",
description="新描述",
tags=["新标签"],
is_active=False,
metadata_={"updated": True},
mock_repo.list_by_user.assert_called_once_with(
"user_1", category="电商", skip=0, limit=50
)
result = update_use_case.execute(command)
def test_list_with_pagination(self, mock_repo, sample_item):
"""带分页参数"""
mock_repo.list_by_user.return_value = [sample_item]
use_case = ListTitleLibraryUseCase(mock_repo)
assert result.name == "更新后标题"
assert result.text == "更新后文本"
assert result.category == "新分类"
assert result.description == "新描述"
assert result.tags == ["新标签"]
assert result.is_active is False
assert result.metadata_ == {"updated": True}
use_case.execute("user_1", skip=10, limit=20)
mock_repo.update.assert_called_once()
def test_update_partial_only_name(self, update_use_case, mock_repo, existing_title_item):
"""测试仅更新 name"""
mock_repo.get.return_value = existing_title_item
command = UpdateTitleLibraryCommand(
title_id="existing-title-001",
user_id="user-001",
name="仅改名",
mock_repo.list_by_user.assert_called_once_with(
"user_1", category=None, skip=10, limit=20
)
result = update_use_case.execute(command)
def test_empty_list(self, mock_repo):
"""空列表"""
mock_repo.list_by_user.return_value = []
use_case = ListTitleLibraryUseCase(mock_repo)
assert result.name == "仅改名"
# 其他字段保持不变
assert result.text == "旧文本"
assert result.category == "旧分类"
assert result.description == "旧描述"
result = use_case.execute("user_1")
def test_update_partial_only_is_active(self, update_use_case, mock_repo, existing_title_item):
"""测试仅更新 is_active(软删除/恢复)"""
mock_repo.get.return_value = existing_title_item
command = UpdateTitleLibraryCommand(
title_id="existing-title-001",
user_id="user-001",
is_active=False,
)
result = update_use_case.execute(command)
assert result.is_active is False
assert result.name == "旧标题" # 其他字段不变
def test_update_not_found(self, update_use_case, mock_repo):
"""测试更新不存在的条目"""
mock_repo.get.return_value = None
command = UpdateTitleLibraryCommand(
title_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
command = UpdateTitleLibraryCommand(
title_id="existing-title-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_title_item):
"""测试 None 字段不覆盖原有值"""
mock_repo.get.return_value = existing_title_item
command = UpdateTitleLibraryCommand(
title_id="existing-title-001",
user_id="user-001",
)
result = update_use_case.execute(command)
assert result.name == "旧标题"
assert result.text == "旧文本"
assert result.category == "旧分类"
assert result.is_active is True
# ===========================================================================
# 4. DeleteTitleLibraryUseCase 测试
# ===========================================================================
class TestDeleteTitleLibraryUseCase:
"""标题库删除 UseCase 测试"""
def test_delete_success(self, mock_repo):
"""测试删除成功"""
mock_repo.delete.return_value = True
use_case = DeleteTitleLibraryUseCase(repository=mock_repo)
result = use_case.execute("title-001", "user-001")
assert result is True
mock_repo.delete.assert_called_once_with("title-001", "user-001")
def test_delete_not_found(self, mock_repo):
"""测试删除不存在的条目"""
mock_repo.delete.return_value = False
use_case = DeleteTitleLibraryUseCase(repository=mock_repo)
result = use_case.execute("nonexistent", "user-001")
assert result is False
# ===========================================================================
# 5. GetTitleLibraryUseCase 测试
# ===========================================================================
assert result == []
class TestGetTitleLibraryUseCase:
"""标题库查询 UseCase 测试"""
"""GetTitleLibraryUseCase 测试"""
def test_get_existing(self, mock_repo):
"""测试查询存在的条目"""
expected = TitleLibraryItem(
id="t-001",
user_id="user-001",
name="测试",
text="文本",
)
mock_repo.get.return_value = expected
use_case = GetTitleLibraryUseCase(repository=mock_repo)
def test_get_existing(self, mock_repo, sample_item):
"""获取存在的标题"""
mock_repo.get.return_value = sample_item
use_case = GetTitleLibraryUseCase(mock_repo)
result = use_case.execute("t-001", "user-001")
result = use_case.execute("title_1", "user_1")
assert result is not None
assert result.id == "t-001"
mock_repo.get.assert_called_once_with("t-001", "user-001")
assert result.id == "title_1"
mock_repo.get.assert_called_once_with("title_1", "user_1")
def test_get_not_found(self, mock_repo):
"""测试查询不存在的条目"""
def test_get_nonexistent_returns_none(self, mock_repo):
"""获取不存在的标题返回 None"""
mock_repo.get.return_value = None
use_case = GetTitleLibraryUseCase(repository=mock_repo)
use_case = GetTitleLibraryUseCase(mock_repo)
result = use_case.execute("nonexistent", "user-001")
result = use_case.execute("nonexistent", "user_1")
assert result is None
# ===========================================================================
# 6. ListTitleLibraryUseCase 测试
# ===========================================================================
class TestCreateTitleLibraryUseCase:
"""CreateTitleLibraryUseCase 测试"""
def test_create_success(self, mock_repo, sample_item):
"""创建成功"""
mock_repo.count_by_user.return_value = 0
mock_repo.create.return_value = sample_item
use_case = CreateTitleLibraryUseCase(mock_repo)
command = CreateTitleLibraryCommand(
user_id="user_1",
name="新标题",
text="新标题文案",
category="default",
description="",
tags=[],
metadata_={},
)
result = use_case.execute(command, plan_name="free")
assert result.id == "title_1"
mock_repo.count_by_user.assert_called_once_with("user_1")
mock_repo.create.assert_called_once()
def test_create_quota_exceeded(self, mock_repo):
"""超过配额时抛出 QuotaExceededError"""
mock_repo.count_by_user.return_value = 9999
use_case = CreateTitleLibraryUseCase(mock_repo)
command = CreateTitleLibraryCommand(
user_id="user_1",
name="新标题",
text="文案",
category="default",
description="",
tags=[],
metadata_={},
)
with pytest.raises(QuotaExceededError):
use_case.execute(command, plan_name="free")
mock_repo.create.assert_not_called()
def test_create_with_tags_and_metadata(self, mock_repo, sample_item):
"""创建时带 tags 和 metadata_"""
mock_repo.count_by_user.return_value = 0
mock_repo.create.return_value = sample_item
use_case = CreateTitleLibraryUseCase(mock_repo)
command = CreateTitleLibraryCommand(
user_id="user_1",
name="带标签标题",
text="文案",
category="电商",
description="测试描述",
tags=["爆款", "促销"],
metadata_={"source": "manual"},
)
use_case.execute(command, plan_name="premium")
created = mock_repo.create.call_args[0][0]
assert isinstance(created, TitleLibraryItem)
assert created.name == "带标签标题"
assert created.category == "电商"
assert created.tags == ["爆款", "促销"]
assert created.metadata_ == {"source": "manual"}
class TestListTitleLibraryUseCase:
"""标题库列表 UseCase 测试"""
class TestUpdateTitleLibraryUseCase:
"""UpdateTitleLibraryUseCase 测试"""
def test_list_default(self, mock_repo):
"""测试默认列表查询"""
def test_update_name(self, mock_repo, sample_item):
"""更新标题名称"""
mock_repo.get.return_value = sample_item
mock_repo.update.side_effect = lambda x: x
use_case = UpdateTitleLibraryUseCase(mock_repo)
command = UpdateTitleLibraryCommand(
title_id="title_1", user_id="user_1", name="新名称"
)
result = use_case.execute(command)
assert result.name == "新名称"
# 其他字段不变
assert result.text == "这是一个爆款标题文案"
mock_repo.get.assert_called_once_with("title_1", "user_1")
mock_repo.update.assert_called_once()
def test_update_multiple_fields(self, mock_repo, sample_item):
"""同时更新多个字段"""
mock_repo.get.return_value = sample_item
mock_repo.update.side_effect = lambda x: x
use_case = UpdateTitleLibraryUseCase(mock_repo)
command = UpdateTitleLibraryCommand(
title_id="title_1",
user_id="user_1",
text="新文案内容",
category="美食",
is_active=False,
)
result = use_case.execute(command)
assert result.text == "新文案内容"
assert result.category == "美食"
assert result.is_active is False
def test_update_nonexistent_raises(self, mock_repo):
"""更新不存在的标题抛出 NotFoundError"""
mock_repo.get.return_value = None
use_case = UpdateTitleLibraryUseCase(mock_repo)
command = UpdateTitleLibraryCommand(
title_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 TestDeleteTitleLibraryUseCase:
"""DeleteTitleLibraryUseCase 测试"""
def test_delete_success(self, mock_repo):
"""删除成功"""
mock_repo.delete.return_value = True
use_case = DeleteTitleLibraryUseCase(mock_repo)
result = use_case.execute("title_1", "user_1")
assert result is True
mock_repo.delete.assert_called_once_with("title_1", "user_1")
def test_delete_nonexistent_returns_false(self, mock_repo):
"""删除不存在的返回 False"""
mock_repo.delete.return_value = False
use_case = DeleteTitleLibraryUseCase(mock_repo)
result = use_case.execute("noexist", "user_1")
assert result is False
class TestIncrementTitleUsageUseCase:
"""IncrementTitleUsageUseCase 测试"""
def test_increment_positive(self, mock_repo):
"""正增量时调用 repository"""
mock_repo.increment_usage_count.return_value = True
use_case = IncrementTitleUsageUseCase(mock_repo)
command = IncrementTitleUsageCommand(
title_id="title_1", user_id="user_1", increment=1
)
result = use_case.execute(command)
assert result is True
mock_repo.increment_usage_count.assert_called_once_with(
"title_1", "user_1", increment=1
)
def test_increment_zero_returns_false(self, mock_repo):
"""增量为0返回False,不调用repository"""
use_case = IncrementTitleUsageUseCase(mock_repo)
command = IncrementTitleUsageCommand(
title_id="title_1", user_id="user_1", increment=0
)
result = use_case.execute(command)
assert result is False
mock_repo.increment_usage_count.assert_not_called()
def test_increment_negative_returns_false(self, mock_repo):
"""负增量返回False"""
use_case = IncrementTitleUsageUseCase(mock_repo)
command = IncrementTitleUsageCommand(
title_id="title_1", user_id="user_1", increment=-1
)
result = use_case.execute(command)
assert result is False
mock_repo.increment_usage_count.assert_not_called()
def test_increment_large_number(self, mock_repo):
"""大增量值"""
mock_repo.increment_usage_count.return_value = True
use_case = IncrementTitleUsageUseCase(mock_repo)
command = IncrementTitleUsageCommand(
title_id="title_1", user_id="user_1", increment=10
)
use_case.execute(command)
mock_repo.increment_usage_count.assert_called_once_with(
"title_1", "user_1", increment=10
)
class TestPickTitleUseCase:
"""PickTitleUseCase 智能选标题测试"""
def test_pick_from_multiple(self, mock_repo):
"""从多个标题中选一个(最少使用的前5个中随机)"""
items = [
TitleLibraryItem(id="t1", user_id="user-001", name="A", text="a"),
TitleLibraryItem(id="t2", user_id="user-001", name="B", text="b"),
_make_item(f"t{i}", f"标题{i}", f"文案{i}", usage_count=i)
for i in range(10)
]
mock_repo.list_by_user.return_value = items
use_case = ListTitleLibraryUseCase(repository=mock_repo)
use_case = PickTitleUseCase(mock_repo)
result = use_case.execute("user-001")
command = PickTitleCommand(user_id="user_1")
result = use_case.execute(command)
assert len(result) == 2
mock_repo.list_by_user.assert_called_once_with("user-001", category=None, skip=0, limit=50)
assert result is not None
assert isinstance(result, TitleLibraryItem)
# 选出的应该是使用次数最少的前5个之一(0-4)
assert result.usage_count <= 4
mock_repo.list_by_user.assert_called_once()
def test_list_with_category_filter(self, mock_repo):
"""测试按分类筛选"""
def test_pick_empty_returns_none(self, mock_repo):
"""空标题库返回 None"""
mock_repo.list_by_user.return_value = []
use_case = ListTitleLibraryUseCase(repository=mock_repo)
use_case = PickTitleUseCase(mock_repo)
use_case.execute("user-001", category="新闻", skip=5, limit=10)
command = PickTitleCommand(user_id="user_1")
result = use_case.execute(command)
mock_repo.list_by_user.assert_called_once_with("user-001", category="新闻", skip=5, limit=10)
assert result is None
def test_list_empty(self, mock_repo):
"""测试空列表"""
mock_repo.list_by_user.return_value = []
use_case = ListTitleLibraryUseCase(repository=mock_repo)
def test_pick_with_category(self, mock_repo):
"""按分类选标题"""
items = [_make_item("t1", "标题1", "文案1", category="美食")]
mock_repo.list_by_user.return_value = items
use_case = PickTitleUseCase(mock_repo)
result = use_case.execute("user-001")
command = PickTitleCommand(user_id="user_1", category="美食")
result = use_case.execute(command)
assert result == []
assert result is not None
call_kwargs = mock_repo.list_by_user.call_args[1]
assert call_kwargs["category"] == "美食"
assert call_kwargs["is_active"] is True
def test_pick_exclude_ids(self, mock_repo):
"""排除指定ID"""
items = [
_make_item("t1", "标题1", "文案1", usage_count=1),
_make_item("t2", "标题2", "文案2", usage_count=2),
_make_item("t3", "标题3", "文案3", usage_count=3),
]
mock_repo.list_by_user.return_value = items
use_case = PickTitleUseCase(mock_repo)
command = PickTitleCommand(user_id="user_1", exclude_ids=["t1", "t2"])
result = use_case.execute(command)
# 排除两个后只剩t3
assert result.id == "t3"
def test_pick_exclude_all_falls_back(self, mock_repo):
"""排除全部时从所有标题中选"""
items = [
_make_item("t1", "标题1", "文案1", usage_count=1),
_make_item("t2", "标题2", "文案2", usage_count=2),
]
mock_repo.list_by_user.return_value = items
use_case = PickTitleUseCase(mock_repo)
command = PickTitleCommand(user_id="user_1", exclude_ids=["t1", "t2"])
result = use_case.execute(command)
# 排除全部后fallback到全部,所以还是能选出一个
assert result is not None
assert result.id in ("t1", "t2")
def test_pick_single_item(self, mock_repo):
"""只有一个标题时选它"""
item = _make_item("only", "唯一标题", "唯一文案", usage_count=10)
mock_repo.list_by_user.return_value = [item]
use_case = PickTitleUseCase(mock_repo)
command = PickTitleCommand(user_id="user_1")
result = use_case.execute(command)
assert result.id == "only"
def test_pick_prefers_less_used(self, mock_repo):
"""倾向于选择使用次数少的"""
items = [
_make_item("t_used", "常用", "常用", usage_count=100),
_make_item("t_fresh", "新的", "新的", usage_count=0),
]
mock_repo.list_by_user.return_value = items
use_case = PickTitleUseCase(mock_repo)
# 跑多次,验证使用少的出现在候选池里
results = set()
for _ in range(20):
command = PickTitleCommand(user_id="user_1")
r = use_case.execute(command)
if r:
results.add(r.id)
# 两个都在候选池(少于5个),所以都可能被选中
assert "t_used" in results or "t_fresh" in results