video-compliance-ai/backend/tests/test_rules_api.py
Your Name e4959d584f feat: 完善代理商端业务逻辑与前后端框架
主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:27:31 +08:00

386 lines
13 KiB
Python

"""
规则管理 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