""" 标题库(Title Library)Use Case 回归测试 测试目标: 1. CreateTitleLibraryUseCase - 创建标题库条目 2. UpdateTitleLibraryUseCase - 更新标题库条目 3. 配额逻辑覆盖 - titles: free=50, basic=500, premium=500 4. 边界条件与异常场景 """ from unittest.mock import Mock import pytest from packages.application.title_library.commands import ( CreateTitleLibraryCommand, UpdateTitleLibraryCommand, ) from packages.application.title_library.use_cases import ( CreateTitleLibraryUseCase, DeleteTitleLibraryUseCase, GetTitleLibraryUseCase, ListTitleLibraryUseCase, NotFoundError, QuotaExceededError, UpdateTitleLibraryUseCase, ) 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(): """模拟已存在的标题条目""" return TitleLibraryItem( id="existing-title-001", user_id="user-001", name="旧标题", text="旧文本", category="旧分类", description="旧描述", tags=["旧"], is_active=True, metadata_={}, ) # =========================================================================== # 1. CreateTitleLibraryUseCase 测试 # =========================================================================== class TestCreateTitleLibraryUseCase: """标题库创建 UseCase 测试""" 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"} mock_repo.count_by_user.assert_called_once_with("user-001") mock_repo.create.assert_called_once() 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") 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="文本", ) result = create_use_case.execute(command, plan_name="free") assert result.category == "default" assert result.description == "" assert result.tags == [] assert result.metadata_ == {} # =========================================================================== # 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}, ) result = update_use_case.execute(command) 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} 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="仅改名", ) result = update_use_case.execute(command) assert result.name == "仅改名" # 其他字段保持不变 assert result.text == "旧文本" assert result.category == "旧分类" assert result.description == "旧描述" 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 测试 # =========================================================================== class TestGetTitleLibraryUseCase: """标题库查询 UseCase 测试""" 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) result = use_case.execute("t-001", "user-001") assert result is not None assert result.id == "t-001" mock_repo.get.assert_called_once_with("t-001", "user-001") def test_get_not_found(self, mock_repo): """测试查询不存在的条目""" mock_repo.get.return_value = None use_case = GetTitleLibraryUseCase(repository=mock_repo) result = use_case.execute("nonexistent", "user-001") assert result is None # =========================================================================== # 6. ListTitleLibraryUseCase 测试 # =========================================================================== class TestListTitleLibraryUseCase: """标题库列表 UseCase 测试""" def test_list_default(self, mock_repo): """测试默认列表查询""" items = [ TitleLibraryItem(id="t1", user_id="user-001", name="A", text="a"), TitleLibraryItem(id="t2", user_id="user-001", name="B", text="b"), ] mock_repo.list_by_user.return_value = items use_case = ListTitleLibraryUseCase(repository=mock_repo) result = use_case.execute("user-001") assert len(result) == 2 mock_repo.list_by_user.assert_called_once_with("user-001", category=None, skip=0, limit=50) def test_list_with_category_filter(self, mock_repo): """测试按分类筛选""" mock_repo.list_by_user.return_value = [] use_case = ListTitleLibraryUseCase(repository=mock_repo) use_case.execute("user-001", category="新闻", skip=5, limit=10) mock_repo.list_by_user.assert_called_once_with("user-001", category="新闻", skip=5, limit=10) def test_list_empty(self, mock_repo): """测试空列表""" mock_repo.list_by_user.return_value = [] use_case = ListTitleLibraryUseCase(repository=mock_repo) result = use_case.execute("user-001") assert result == []