""" 脚本预审 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)