"""标题库 UseCase 单元测试.""" from __future__ import annotations 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, PickTitleUseCase, UpdateTitleLibraryUseCase, ) from packages.domain.exceptions import NotFoundError, QuotaExceededError from packages.domain.title_library import TitleLibraryItem def _make_item(id: str, name: str, text: str, usage_count: int = 0, category: str = "default") -> TitleLibraryItem: return TitleLibraryItem( id=id, user_id="user_1", name=name, text=text, category=category, description="", tags=[], usage_count=usage_count, is_active=True, metadata_={}, ) @pytest.fixture def mock_repo(): return MagicMock() @pytest.fixture def sample_item(): return _make_item("title_1", "爆款标题", "这是一个爆款标题文案", usage_count=5) class TestListTitleLibraryUseCase: """ListTitleLibraryUseCase 测试""" def test_list_returns_results(self, mock_repo, sample_item): """正常返回标题列表""" mock_repo.list_by_user.return_value = [sample_item] use_case = ListTitleLibraryUseCase(mock_repo) result = use_case.execute("user_1") 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) def test_list_with_category(self, mock_repo, sample_item): """按分类过滤""" mock_repo.list_by_user.return_value = [sample_item] use_case = ListTitleLibraryUseCase(mock_repo) use_case.execute("user_1", category="电商") mock_repo.list_by_user.assert_called_once_with("user_1", category="电商", skip=0, limit=50) def test_list_with_pagination(self, mock_repo, sample_item): """带分页参数""" mock_repo.list_by_user.return_value = [sample_item] use_case = ListTitleLibraryUseCase(mock_repo) use_case.execute("user_1", skip=10, limit=20) mock_repo.list_by_user.assert_called_once_with("user_1", category=None, skip=10, limit=20) def test_empty_list(self, mock_repo): """空列表""" mock_repo.list_by_user.return_value = [] use_case = ListTitleLibraryUseCase(mock_repo) result = use_case.execute("user_1") assert result == [] class TestGetTitleLibraryUseCase: """GetTitleLibraryUseCase 测试""" 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("title_1", "user_1") assert result is not None assert result.id == "title_1" mock_repo.get.assert_called_once_with("title_1", "user_1") def test_get_nonexistent_returns_none(self, mock_repo): """获取不存在的标题返回 None""" mock_repo.get.return_value = None use_case = GetTitleLibraryUseCase(mock_repo) result = use_case.execute("nonexistent", "user_1") assert result is None 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 TestUpdateTitleLibraryUseCase: """UpdateTitleLibraryUseCase 测试""" 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 = [_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 = PickTitleUseCase(mock_repo) command = PickTitleCommand(user_id="user_1") result = use_case.execute(command) 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_pick_empty_returns_none(self, mock_repo): """空标题库返回 None""" mock_repo.list_by_user.return_value = [] use_case = PickTitleUseCase(mock_repo) command = PickTitleCommand(user_id="user_1") result = use_case.execute(command) assert result is None 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) command = PickTitleCommand(user_id="user_1", category="美食") result = use_case.execute(command) 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