主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
386 lines
13 KiB
Python
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
|