主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""
|
||
脚本预审 API 测试 (TDD - 红色阶段)
|
||
测试覆盖: 脚本提交、违规检测、语境理解
|
||
"""
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
from app.schemas.review import ScriptReviewResponse, ViolationType, SoftRiskAction
|
||
|
||
|
||
class TestSubmitScript:
|
||
"""提交脚本预审"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_script_returns_200(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""提交脚本返回 200"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是一段测试脚本内容",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
assert response.status_code == 200
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_script_returns_review_result(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""提交脚本返回审核结果"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是一段测试脚本内容",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert isinstance(parsed.summary, str) and parsed.summary
|
||
assert 0 <= parsed.score <= 100
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_empty_script_returns_422(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""提交空脚本返回 422"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
|
||
class TestForbiddenWordDetection:
|
||
"""违禁词检测"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_absolute_word(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""检测广告极限词:最好、第一"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "我们的产品是全网最好的,销量第一",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0
|
||
violation_types = [v.type for v in parsed.violations]
|
||
assert ViolationType.FORBIDDEN_WORD in violation_types
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_efficacy_word(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""检测功效词:根治、治愈"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "使用我们的产品可以根治失眠问题",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
violation_types = [v.type for v in parsed.violations]
|
||
assert ViolationType.EFFICACY_CLAIM in violation_types
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_return_violation_position(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""返回违规词位置"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是最好的产品", # "最好"是违禁词
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0, "应检测到'最好'违规"
|
||
violation = parsed.violations[0]
|
||
assert violation.position is not None
|
||
assert violation.position.start >= 0
|
||
assert violation.position.end > violation.position.start
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_return_violation_suggestion(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""每个违规项包含修改建议"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是最好的产品", # "最好"是违禁词
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0, "应检测到'最好'违规"
|
||
assert isinstance(parsed.violations[0].suggestion, str)
|
||
assert parsed.violations[0].suggestion
|
||
|
||
|
||
class TestContextUnderstanding:
|
||
"""语境理解(降低误报)"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_non_ad_context_not_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""非广告语境不应标记为违规:最开心的一天"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "今天是我最开心的一天,因为见到了老朋友",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
forbidden_violations = [
|
||
v for v in parsed.violations
|
||
if v.type == ViolationType.FORBIDDEN_WORD and "最" in v.content
|
||
]
|
||
assert len(forbidden_violations) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_story_context_not_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""故事情节语境不应标记:他是第一个到达的人"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "他是第一个到达终点的人,大家都为他鼓掌",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
forbidden_violations = [
|
||
v for v in parsed.violations
|
||
if v.type == ViolationType.FORBIDDEN_WORD and "第一" in v.content
|
||
]
|
||
assert len(forbidden_violations) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ad_context_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""广告语境应标记:我们的产品第一"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "我们的产品销量第一,品质最好",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0
|
||
|
||
|
||
class TestSellingPointCheck:
|
||
"""卖点遗漏检查"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_check_missing_selling_points(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""检查是否遗漏必要卖点"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这个产品很好用",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"required_points": ["功效说明", "使用方法", "品牌名称"],
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert parsed.missing_points is not None
|
||
assert isinstance(parsed.missing_points, list)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_all_points_covered(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""所有卖点都覆盖时返回空"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "品牌A的护肤精华,每天早晚各用一次,可以让肌肤更水润",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"required_points": ["品牌名称", "使用方法", "功效说明"],
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert parsed.missing_points == []
|
||
|
||
|
||
class TestScoreCalculation:
|
||
"""合规分数计算"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_clean_content_returns_high_score(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""合规内容返回高分(>=90)"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "今天给大家分享一个护肤小技巧,记得每天早晚洁面哦",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert parsed.score >= 90
|
||
high_risk = [v for v in parsed.violations if v.severity.value == "high"]
|
||
assert len(high_risk) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_violation_content_returns_low_score(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""违规内容返回低分(<80)"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是最好的产品,可以根治所有问题,效果第一",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert parsed.score < 80
|
||
assert len(parsed.violations) > 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_score_range_valid(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""分数在有效范围内 0-100"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "任意内容",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert 0 <= parsed.score <= 100
|
||
|
||
|
||
class TestSoftRiskWarnings:
|
||
"""软性风控提示"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_near_threshold_returns_warning(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""临界值接近阈值时返回软性提示(不阻断)"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "内容正常但指标接近阈值",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"soft_risk_context": {
|
||
"violation_rate": 0.045,
|
||
"violation_threshold": 0.05,
|
||
}
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
matched = [
|
||
w for w in parsed.soft_warnings
|
||
if w.code == "NEAR_THRESHOLD" and w.action_required == SoftRiskAction.CONFIRM
|
||
]
|
||
assert matched, "应返回临界值软性提示"
|
||
assert all(w.blocking is False for w in matched)
|