""" 规则管理 API 测试 (TDD - 红色阶段) 测试覆盖: 违禁词库、白名单、竞品库、平台规则 """ import pytest from httpx import AsyncClient from app.schemas.review import ScriptReviewResponse, ViolationType class TestForbiddenWords: """违禁词库管理""" @pytest.mark.asyncio async def test_list_forbidden_words_returns_200(self, client: AsyncClient, tenant_id: str): """查询违禁词列表返回 200""" response = await client.get( "/api/v1/rules/forbidden-words", headers={"X-Tenant-ID": tenant_id}, ) assert response.status_code == 200 @pytest.mark.asyncio async def test_list_forbidden_words_returns_array(self, client: AsyncClient, tenant_id: str): """查询违禁词返回数组""" response = await client.get( "/api/v1/rules/forbidden-words", headers={"X-Tenant-ID": tenant_id}, ) data = response.json() assert "items" in data assert isinstance(data["items"], list) @pytest.mark.asyncio async def test_forbidden_word_has_category(self, client: AsyncClient, tenant_id: str): """违禁词包含分类信息""" response = await client.get( "/api/v1/rules/forbidden-words", headers={"X-Tenant-ID": tenant_id}, ) data = response.json() if data["items"]: word = data["items"][0] assert "category" in word # 极限词、功效词、敏感词等 assert "word" in word @pytest.mark.asyncio async def test_add_forbidden_word_returns_201(self, client: AsyncClient, tenant_id: str, forbidden_word: str): """添加违禁词返回 201""" response = await client.post( "/api/v1/rules/forbidden-words", headers={"X-Tenant-ID": tenant_id}, json={ "word": forbidden_word, "category": "custom", "severity": "medium", } ) assert response.status_code == 201 data = response.json() assert data.get("id") assert data.get("word") == forbidden_word assert data.get("category") == "custom" assert data.get("severity") == "medium" @pytest.mark.asyncio async def test_add_duplicate_word_returns_409(self, client: AsyncClient, tenant_id: str, forbidden_word: str): """添加重复违禁词返回 409""" headers = {"X-Tenant-ID": tenant_id} # 先添加一次 await client.post( "/api/v1/rules/forbidden-words", headers=headers, json={"word": forbidden_word, "category": "custom", "severity": "medium"} ) # 再次添加 response = await client.post( "/api/v1/rules/forbidden-words", headers=headers, json={"word": forbidden_word, "category": "custom", "severity": "medium"} ) assert response.status_code == 409 @pytest.mark.asyncio async def test_delete_forbidden_word_returns_204(self, client: AsyncClient, tenant_id: str, forbidden_word: str): """删除违禁词返回 204""" headers = {"X-Tenant-ID": tenant_id} # 先添加 create_resp = await client.post( "/api/v1/rules/forbidden-words", headers=headers, json={"word": forbidden_word, "category": "custom", "severity": "low"} ) word_id = create_resp.json()["id"] # 删除 response = await client.delete( f"/api/v1/rules/forbidden-words/{word_id}", headers=headers, ) assert response.status_code == 204 @pytest.mark.asyncio async def test_filter_by_category(self, client: AsyncClient, tenant_id: str): """按分类筛选违禁词""" response = await client.get( "/api/v1/rules/forbidden-words?category=absolute", headers={"X-Tenant-ID": tenant_id}, ) assert response.status_code == 200 class TestWhitelist: """白名单管理""" @pytest.mark.asyncio async def test_list_whitelist_returns_200(self, client: AsyncClient, tenant_id: str): """查询白名单返回 200""" response = await client.get( "/api/v1/rules/whitelist", headers={"X-Tenant-ID": tenant_id}, ) assert response.status_code == 200 @pytest.mark.asyncio async def test_add_to_whitelist_returns_201(self, client: AsyncClient, tenant_id: str, whitelist_term: str, brand_id: str): """添加白名单返回 201""" response = await client.post( "/api/v1/rules/whitelist", headers={"X-Tenant-ID": tenant_id}, json={ "term": whitelist_term, "reason": "品牌方授权使用", "brand_id": brand_id, } ) assert response.status_code == 201 data = response.json() assert data.get("id") assert data.get("term") == whitelist_term assert data.get("brand_id") == brand_id @pytest.mark.asyncio async def test_whitelist_overrides_forbidden(self, client: AsyncClient, tenant_id: str, whitelist_term: str, brand_id: str): """白名单覆盖违禁词检测""" headers = {"X-Tenant-ID": tenant_id} # 先添加到白名单 await client.post( "/api/v1/rules/whitelist", headers=headers, json={ "term": whitelist_term, "reason": "品牌 slogan", "brand_id": brand_id, } ) # 提交包含该词的脚本 response = await client.post( "/api/v1/scripts/review", headers=headers, json={ "content": f"我们是您的{whitelist_term}", "platform": "douyin", "brand_id": brand_id, } ) data = response.json() parsed = ScriptReviewResponse.model_validate(data) flagged_words = [ v.content for v in parsed.violations if v.type == ViolationType.FORBIDDEN_WORD ] assert whitelist_term not in flagged_words @pytest.mark.asyncio async def test_whitelist_scoped_to_brand(self, client: AsyncClient, tenant_id: str, whitelist_term: str, brand_id: str, other_brand_id: str): """白名单仅对指定品牌生效""" headers = {"X-Tenant-ID": tenant_id} # 为 brand-001 添加白名单 await client.post( "/api/v1/rules/whitelist", headers=headers, json={ "term": whitelist_term, "reason": "品牌方授权", "brand_id": brand_id, } ) # 其他品牌提交应该仍被标记 response = await client.post( "/api/v1/scripts/review", headers=headers, json={ "content": f"这是{whitelist_term}", "platform": "douyin", "brand_id": other_brand_id, # 不同品牌 } ) data = response.json() parsed = ScriptReviewResponse.model_validate(data) assert len(parsed.violations) > 0 or parsed.score < 100 class TestCompetitorList: """竞品库管理""" @pytest.mark.asyncio async def test_list_competitors_returns_200(self, client: AsyncClient, tenant_id: str, brand_id: str): """查询竞品列表返回 200""" response = await client.get( f"/api/v1/rules/competitors?brand_id={brand_id}", headers={"X-Tenant-ID": tenant_id}, ) assert response.status_code == 200 @pytest.mark.asyncio async def test_add_competitor_returns_201(self, client: AsyncClient, tenant_id: str, competitor_name: str, brand_id: str): """添加竞品返回 201""" response = await client.post( "/api/v1/rules/competitors", headers={"X-Tenant-ID": tenant_id}, json={ "name": competitor_name, "brand_id": brand_id, "logo_url": "https://example.com/competitor-logo.png", "keywords": [competitor_name], } ) assert response.status_code == 201 data = response.json() assert data.get("id") assert data.get("name") == competitor_name assert data.get("brand_id") == brand_id @pytest.mark.asyncio async def test_competitor_has_logo(self, client: AsyncClient, tenant_id: str, competitor_name: str, brand_id: str): """竞品包含 Logo 信息(用于视觉检测)""" headers = {"X-Tenant-ID": tenant_id} await client.post( "/api/v1/rules/competitors", headers=headers, json={ "name": competitor_name, "brand_id": brand_id, "logo_url": "https://example.com/logo-b.png", "keywords": [competitor_name], } ) response = await client.get( f"/api/v1/rules/competitors?brand_id={brand_id}", headers=headers, ) data = response.json() competitors = data.get("items", []) target = next((c for c in competitors if c.get("name") == competitor_name), None) assert target is not None assert target.get("logo_url") @pytest.mark.asyncio async def test_delete_competitor_returns_204(self, client: AsyncClient, tenant_id: str, competitor_name: str, brand_id: str): """删除竞品返回 204""" headers = {"X-Tenant-ID": tenant_id} create_resp = await client.post( "/api/v1/rules/competitors", headers=headers, json={ "name": competitor_name, "brand_id": brand_id, "keywords": [competitor_name], } ) competitor_id = create_resp.json()["id"] response = await client.delete( f"/api/v1/rules/competitors/{competitor_id}", headers=headers, ) assert response.status_code == 204 class TestPlatformRules: """平台规则管理""" @pytest.mark.asyncio async def test_list_platform_rules_returns_200(self, client: AsyncClient, tenant_id: str): """查询平台规则返回 200""" response = await client.get( "/api/v1/rules/platforms", headers={"X-Tenant-ID": tenant_id}, ) assert response.status_code == 200 @pytest.mark.asyncio async def test_get_platform_rules_by_name(self, client: AsyncClient, tenant_id: str): """按平台名称查询规则""" response = await client.get( "/api/v1/rules/platforms/douyin", headers={"X-Tenant-ID": tenant_id}, ) assert response.status_code == 200 data = response.json() assert data["platform"] == "douyin" assert "rules" in data @pytest.mark.asyncio async def test_platform_rules_have_version(self, client: AsyncClient, tenant_id: str): """平台规则包含版本信息""" response = await client.get( "/api/v1/rules/platforms/douyin", headers={"X-Tenant-ID": tenant_id}, ) data = response.json() assert "version" in data assert "updated_at" in data @pytest.mark.asyncio async def test_supported_platforms(self, client: AsyncClient, tenant_id: str): """支持的平台列表""" response = await client.get( "/api/v1/rules/platforms", headers={"X-Tenant-ID": tenant_id}, ) data = response.json() platforms = [p["platform"] for p in data["items"]] assert "douyin" in platforms assert "xiaohongshu" in platforms assert "bilibili" in platforms class TestRuleConflictDetection: """规则冲突检测""" @pytest.mark.asyncio async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str): """检测 Brief 与平台规则冲突""" response = await client.post( "/api/v1/rules/validate", headers={"X-Tenant-ID": tenant_id}, json={ "brand_id": brand_id, "platform": "douyin", "brief_rules": { "required_phrases": ["绝对有效"], # 可能违反平台规则 } } ) assert response.status_code == 200 data = response.json() assert "conflicts" in data assert isinstance(data["conflicts"], list) assert len(data["conflicts"]) > 0 @pytest.mark.asyncio async def test_conflict_includes_details(self, client: AsyncClient, tenant_id: str, brand_id: str): """冲突检测包含详细信息""" response = await client.post( "/api/v1/rules/validate", headers={"X-Tenant-ID": tenant_id}, json={ "brand_id": brand_id, "platform": "douyin", "brief_rules": { "required_phrases": ["最好的产品"], } } ) data = response.json() assert data.get("conflicts") conflict = data["conflicts"][0] assert "brief_rule" in conflict assert "platform_rule" in conflict assert "suggestion" in conflict