"""标题库 Use Cases 单元测试 — wave217""" 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 # ── helpers ────────────────────────────────────────────────────────────────── def _make_item( item_id="t1", user_id="u1", name="标题A", text="这是一个标题", category="default", usage_count=0, is_active=True, description="", tags=None, metadata_=None, ): return TitleLibraryItem( id=item_id, user_id=user_id, name=name, text=text, category=category, description=description, tags=tags or [], usage_count=usage_count, is_active=is_active, metadata_=metadata_ or {}, ) # ── ListTitleLibraryUseCase ───────────────────────────────────────────────── class TestListTitleLibraryUseCase: def test_list_default_params(self): items = [_make_item("t1"), _make_item("t2")] repo = MagicMock() repo.list_by_user.return_value = items uc = ListTitleLibraryUseCase(repo) result = uc.execute("u1") assert len(result) == 2 repo.list_by_user.assert_called_once_with("u1", category=None, skip=0, limit=50) def test_list_with_category(self): repo = MagicMock() repo.list_by_user.return_value = [] uc = ListTitleLibraryUseCase(repo) result = uc.execute("u1", category="marketing") repo.list_by_user.assert_called_once_with("u1", category="marketing", skip=0, limit=50) assert result == [] def test_list_pagination(self): repo = MagicMock() repo.list_by_user.return_value = [] uc = ListTitleLibraryUseCase(repo) uc.execute("u1", skip=10, limit=20) repo.list_by_user.assert_called_once_with("u1", category=None, skip=10, limit=20) # ── GetTitleLibraryUseCase ────────────────────────────────────────────────── class TestGetTitleLibraryUseCase: def test_get_found(self): item = _make_item() repo = MagicMock() repo.get.return_value = item uc = GetTitleLibraryUseCase(repo) result = uc.execute("t1", "u1") assert result.id == "t1" repo.get.assert_called_once_with("t1", "u1") def test_get_not_found(self): repo = MagicMock() repo.get.return_value = None uc = GetTitleLibraryUseCase(repo) result = uc.execute("t999", "u1") assert result is None # ── CreateTitleLibraryUseCase ─────────────────────────────────────────────── class TestCreateTitleLibraryUseCase: def test_create_success_free_plan_within_quota(self): repo = MagicMock() repo.count_by_user.return_value = 0 # 已用数量 repo.create.side_effect = lambda x: x uc = CreateTitleLibraryUseCase(repo) cmd = CreateTitleLibraryCommand(user_id="u1", name="好标题", text="这是一个好标题的内容") with patch("packages.application.title_library.use_cases.quota_checker") as mock_qc: mock_result = MagicMock() mock_result.allowed = True mock_result.limit = 10 mock_result.used = 0 mock_qc.check.return_value = mock_result result = uc.execute(cmd, plan_name="free") assert result.name == "好标题" assert result.user_id == "u1" repo.create.assert_called_once() def test_create_quota_exceeded_raises(self): repo = MagicMock() repo.count_by_user.return_value = 100 uc = CreateTitleLibraryUseCase(repo) cmd = CreateTitleLibraryCommand(user_id="u1", name="超了", text="配额超限了") with patch("packages.application.title_library.use_cases.quota_checker") as mock_qc: mock_result = MagicMock() mock_result.allowed = False mock_result.limit = 5 mock_result.used = 5 mock_qc.check.return_value = mock_result with pytest.raises(QuotaExceededError): uc.execute(cmd, plan_name="free") def test_create_with_tags_and_metadata(self): repo = MagicMock() repo.count_by_user.return_value = 0 repo.create.side_effect = lambda x: x uc = CreateTitleLibraryUseCase(repo) cmd = CreateTitleLibraryCommand( user_id="u1", name="标题", text="内容", category="marketing", description="描述", tags=["tag1", "tag2"], metadata_={"key": "value"}, ) with patch("packages.application.title_library.use_cases.quota_checker") as mock_qc: mock_result = MagicMock() mock_result.allowed = True mock_result.limit = 100 mock_result.used = 0 mock_qc.check.return_value = mock_result result = uc.execute(cmd) assert result.category == "marketing" assert result.description == "描述" assert result.tags == ["tag1", "tag2"] assert result.metadata_ == {"key": "value"} # ── UpdateTitleLibraryUseCase ─────────────────────────────────────────────── class TestUpdateTitleLibraryUseCase: def test_update_name(self): existing = _make_item(name="old") repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing uc = UpdateTitleLibraryUseCase(repo) cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", name="new") result = uc.execute(cmd) assert result.name == "new" repo.update.assert_called_once() def test_update_text(self): existing = _make_item(text="old") repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing uc = UpdateTitleLibraryUseCase(repo) cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", text="new text") result = uc.execute(cmd) assert result.text == "new text" def test_update_category(self): existing = _make_item(category="old") repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing uc = UpdateTitleLibraryUseCase(repo) cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", category="new_cat") result = uc.execute(cmd) assert result.category == "new_cat" def test_update_tags(self): existing = _make_item(tags=["old"]) repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing uc = UpdateTitleLibraryUseCase(repo) cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", tags=["a", "b"]) result = uc.execute(cmd) assert result.tags == ["a", "b"] def test_update_is_active(self): existing = _make_item(is_active=True) repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing uc = UpdateTitleLibraryUseCase(repo) cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", is_active=False) result = uc.execute(cmd) assert result.is_active is False def test_update_not_found_raises(self): repo = MagicMock() repo.get.return_value = None uc = UpdateTitleLibraryUseCase(repo) cmd = UpdateTitleLibraryCommand(title_id="t999", user_id="u1", name="x") with pytest.raises(NotFoundError): uc.execute(cmd) def test_update_none_fields_not_modified(self): existing = _make_item(name="keep", category="keep_cat", description="keep_desc") repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing uc = UpdateTitleLibraryUseCase(repo) cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1") # 全None result = uc.execute(cmd) assert result.name == "keep" assert result.category == "keep_cat" assert result.description == "keep_desc" # ── DeleteTitleLibraryUseCase ─────────────────────────────────────────────── class TestDeleteTitleLibraryUseCase: def test_delete_success(self): repo = MagicMock() repo.delete.return_value = True uc = DeleteTitleLibraryUseCase(repo) result = uc.execute("t1", "u1") assert result is True repo.delete.assert_called_once_with("t1", "u1") def test_delete_not_found(self): repo = MagicMock() repo.delete.return_value = False uc = DeleteTitleLibraryUseCase(repo) result = uc.execute("t999", "u1") assert result is False # ── IncrementTitleUsageUseCase ────────────────────────────────────────────── class TestIncrementTitleUsageUseCase: def test_increment_default_1(self): repo = MagicMock() repo.increment_usage_count.return_value = True uc = IncrementTitleUsageUseCase(repo) cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1") result = uc.execute(cmd) assert result is True repo.increment_usage_count.assert_called_once_with("t1", "u1", increment=1) def test_increment_custom_amount(self): repo = MagicMock() repo.increment_usage_count.return_value = True uc = IncrementTitleUsageUseCase(repo) cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1", increment=5) result = uc.execute(cmd) repo.increment_usage_count.assert_called_once_with("t1", "u1", increment=5) def test_increment_zero_returns_false(self): repo = MagicMock() uc = IncrementTitleUsageUseCase(repo) cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1", increment=0) result = uc.execute(cmd) assert result is False repo.increment_usage_count.assert_not_called() def test_increment_negative_returns_false(self): repo = MagicMock() uc = IncrementTitleUsageUseCase(repo) cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1", increment=-1) result = uc.execute(cmd) assert result is False repo.increment_usage_count.assert_not_called() # ── PickTitleUseCase ──────────────────────────────────────────────────────── class TestPickTitleUseCase: def test_pick_from_multiple_returns_least_used_in_pool(self): items = [ _make_item("t1", usage_count=10), _make_item("t2", usage_count=1), # 最少 _make_item("t3", usage_count=5), _make_item("t4", usage_count=3), _make_item("t5", usage_count=8), _make_item("t6", usage_count=2), ] repo = MagicMock() repo.list_by_user.return_value = items uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1") # 由于有随机性,多次验证都在候选池(最少使用的5个)中 for _ in range(10): result = uc.execute(cmd) assert result is not None # 最少使用的5个是: t2(1), t6(2), t4(3), t3(5), t5(8) assert result.id in {"t1", "t2", "t3", "t4", "t5", "t6"} # 选中的一定是使用次数最少的5个之一 (usage_count <= 8) assert result.usage_count <= 8 # 验证查询参数 repo.list_by_user.assert_called() call_args = repo.list_by_user.call_args assert call_args[0][0] == "u1" assert call_args[1]["is_active"] is True def test_pick_empty_returns_none(self): repo = MagicMock() repo.list_by_user.return_value = [] uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1") result = uc.execute(cmd) assert result is None def test_pick_single_item(self): item = _make_item("t1") repo = MagicMock() repo.list_by_user.return_value = [item] uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1") result = uc.execute(cmd) assert result.id == "t1" def test_pick_with_category_filter(self): repo = MagicMock() repo.list_by_user.return_value = [_make_item("t1", category="marketing")] uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1", category="marketing") result = uc.execute(cmd) assert result is not None repo.list_by_user.assert_called_once() assert repo.list_by_user.call_args[1]["category"] == "marketing" def test_pick_exclude_ids(self): items = [ _make_item("t1", usage_count=1), _make_item("t2", usage_count=2), _make_item("t3", usage_count=3), ] repo = MagicMock() repo.list_by_user.return_value = items uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1", exclude_ids=["t1", "t2"]) # 排除 t1, t2 后只剩 t3 result = uc.execute(cmd) assert result.id == "t3" def test_pick_exclude_all_fallback_to_all(self): items = [ _make_item("t1", usage_count=1), _make_item("t2", usage_count=2), ] repo = MagicMock() repo.list_by_user.return_value = items uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1", exclude_ids=["t1", "t2"]) # 排除后没了,回退到从全部选 result = uc.execute(cmd) assert result is not None assert result.id in {"t1", "t2"} def test_pick_pool_size_is_5(self): # 10个标题,使用次数从 1~10 items = [_make_item(f"t{i}", usage_count=i) for i in range(1, 11)] repo = MagicMock() repo.list_by_user.return_value = items uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1") # 运行多次,确保选中的都在前5个使用最少的里(t1~t5, usage 1~5) for _ in range(20): result = uc.execute(cmd) assert int(result.id[1:]) <= 5 # 只从前5个里选 def test_pick_fewer_than_pool_size(self): # 只有3个标题,不足5个池大小 items = [ _make_item("t1", usage_count=3), _make_item("t2", usage_count=1), _make_item("t3", usage_count=2), ] repo = MagicMock() repo.list_by_user.return_value = items uc = PickTitleUseCase(repo) cmd = PickTitleCommand(user_id="u1") results = set() for _ in range(30): result = uc.execute(cmd) results.add(result.id) # 3个都有可能被选中(随机性+少量样本,大概率至少出现2个) assert len(results) >= 1 assert results.issubset({"t1", "t2", "t3"})