test: 补全品牌方平台规则 API 测试覆盖 (22 个新测试)

- TestBrandPlatformRuleParse: 文档解析 6 个用例 (201/400/降级)
- TestBrandPlatformRuleConfirm: 确认生效 5 个用例 (active/编辑/停旧/404/隔离)
- TestBrandPlatformRuleList: 列表查询 5 个用例 (空/筛选/隔离)
- TestBrandPlatformRuleDelete: 删除 4 个用例 (204/实际删除/404/隔离)
- TestBrandPlatformRuleLifecycle: 完整生命周期 1 个用例
- 修复 confirm 端点 flush 后缺少 refresh 导致的 MissingGreenlet 错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-10 13:51:40 +08:00
parent 3a2598c956
commit c17c64cd11
2 changed files with 487 additions and 2 deletions

View File

@ -611,6 +611,7 @@ async def confirm_platform_rule(
rule.parsed_rules = request.parsed_rules.model_dump() rule.parsed_rules = request.parsed_rules.model_dump()
rule.status = RuleStatus.ACTIVE.value rule.status = RuleStatus.ACTIVE.value
await db.flush() await db.flush()
await db.refresh(rule)
return _format_platform_rule(rule) return _format_platform_rule(rule)

View File

@ -1,8 +1,10 @@
""" """
规则管理 API 测试 (TDD - 红色阶段) 规则管理 API 测试
测试覆盖: 违禁词库白名单竞品库平台规则 测试覆盖: 违禁词库白名单竞品库平台规则品牌方平台规则 CRUD
""" """
import json
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient from httpx import AsyncClient
from app.schemas.review import ScriptReviewResponse, ViolationType from app.schemas.review import ScriptReviewResponse, ViolationType
@ -383,3 +385,485 @@ class TestRuleConflictDetection:
assert "brief_rule" in conflict assert "brief_rule" in conflict
assert "platform_rule" in conflict assert "platform_rule" in conflict
assert "suggestion" in conflict assert "suggestion" in conflict
# ==================== 品牌方平台规则(文档上传 + AI 解析) ====================
# Mock AI 解析返回的规则数据
MOCK_PARSED_RULES = {
"forbidden_words": ["绝对有效", "最强", "第一"],
"restricted_words": [
{"word": "推荐", "condition": "不能用于医疗产品", "suggestion": "建议改为'供参考'"}
],
"duration": {"min_seconds": 7, "max_seconds": 60},
"content_requirements": ["必须展示产品正面", "需口播品牌名"],
"other_rules": [
{"rule": "字幕要求", "description": "视频必须添加中文字幕"}
],
}
MOCK_AI_JSON_RESPONSE = json.dumps(MOCK_PARSED_RULES, ensure_ascii=False)
def _mock_ai_client_for_parse():
"""创建用于文档解析的 mock AI 客户端"""
client = MagicMock()
client.chat_completion = AsyncMock(return_value=MagicMock(
content=MOCK_AI_JSON_RESPONSE,
))
client.close = AsyncMock()
return client
async def _create_platform_rule(
client: AsyncClient,
tenant_id: str,
brand_id: str,
platform: str = "douyin",
document_name: str = "规则文档.pdf",
) -> dict:
"""辅助函数:创建一条 draft 平台规则"""
with patch(
"app.api.rules.DocumentParser.download_and_parse",
new_callable=AsyncMock,
return_value="这是平台规则文档内容...",
), patch(
"app.api.rules.AIServiceFactory.get_client",
new_callable=AsyncMock,
return_value=_mock_ai_client_for_parse(),
), patch(
"app.api.rules.AIServiceFactory.get_config",
new_callable=AsyncMock,
return_value=MagicMock(models={"text": "gpt-4o"}),
):
resp = await client.post(
"/api/v1/rules/platform-rules/parse",
headers={"X-Tenant-ID": tenant_id},
json={
"document_url": "https://tos.example.com/rules.pdf",
"document_name": document_name,
"platform": platform,
"brand_id": brand_id,
},
)
return resp
class TestBrandPlatformRuleParse:
"""品牌方平台规则 — 上传文档 + AI 解析"""
@pytest.mark.asyncio
async def test_parse_returns_201_draft(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""上传文档解析返回 201状态为 draft"""
resp = await _create_platform_rule(client, tenant_id, brand_id)
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "draft"
assert data["platform"] == "douyin"
assert data["brand_id"] == brand_id
assert data["id"].startswith("pr-")
assert data["document_name"] == "规则文档.pdf"
@pytest.mark.asyncio
async def test_parse_returns_parsed_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""解析后返回结构化规则"""
resp = await _create_platform_rule(client, tenant_id, brand_id)
data = resp.json()
rules = data["parsed_rules"]
assert "forbidden_words" in rules
assert "restricted_words" in rules
assert "duration" in rules
assert "content_requirements" in rules
assert "other_rules" in rules
assert len(rules["forbidden_words"]) == 3
assert "绝对有效" in rules["forbidden_words"]
@pytest.mark.asyncio
async def test_parse_empty_document_returns_400(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""空文档返回 400"""
with patch(
"app.api.rules.DocumentParser.download_and_parse",
new_callable=AsyncMock,
return_value=" ",
):
resp = await client.post(
"/api/v1/rules/platform-rules/parse",
headers={"X-Tenant-ID": tenant_id},
json={
"document_url": "https://tos.example.com/empty.pdf",
"document_name": "empty.pdf",
"platform": "douyin",
"brand_id": brand_id,
},
)
assert resp.status_code == 400
assert "内容为空" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_parse_unsupported_format_returns_400(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""不支持的文件格式返回 400"""
with patch(
"app.api.rules.DocumentParser.download_and_parse",
new_callable=AsyncMock,
side_effect=ValueError("不支持的文件格式: zip"),
):
resp = await client.post(
"/api/v1/rules/platform-rules/parse",
headers={"X-Tenant-ID": tenant_id},
json={
"document_url": "https://tos.example.com/file.zip",
"document_name": "file.zip",
"platform": "douyin",
"brand_id": brand_id,
},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_parse_ai_failure_returns_empty_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""AI 解析失败时返回空规则结构(降级处理)"""
with patch(
"app.api.rules.DocumentParser.download_and_parse",
new_callable=AsyncMock,
return_value="文档内容...",
), patch(
"app.api.rules.AIServiceFactory.get_client",
new_callable=AsyncMock,
return_value=None,
):
resp = await client.post(
"/api/v1/rules/platform-rules/parse",
headers={"X-Tenant-ID": tenant_id},
json={
"document_url": "https://tos.example.com/rules.pdf",
"document_name": "rules.pdf",
"platform": "douyin",
"brand_id": brand_id,
},
)
assert resp.status_code == 201
rules = resp.json()["parsed_rules"]
assert rules["forbidden_words"] == []
assert rules["content_requirements"] == []
assert rules["duration"] is None
@pytest.mark.asyncio
async def test_parse_multiple_platforms(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""同一品牌方可以上传不同平台的规则"""
r1 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
r2 = await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
assert r1.status_code == 201
assert r2.status_code == 201
assert r1.json()["platform"] == "douyin"
assert r2.json()["platform"] == "xiaohongshu"
class TestBrandPlatformRuleConfirm:
"""品牌方平台规则 — 确认/生效"""
@pytest.mark.asyncio
async def test_confirm_sets_active(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""确认规则后状态变为 active"""
# 先创建 draft
create_resp = await _create_platform_rule(client, tenant_id, brand_id)
rule_id = create_resp.json()["id"]
# 确认
confirm_resp = await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers={"X-Tenant-ID": tenant_id},
json={
"parsed_rules": MOCK_PARSED_RULES,
},
)
assert confirm_resp.status_code == 200
data = confirm_resp.json()
assert data["status"] == "active"
assert data["id"] == rule_id
@pytest.mark.asyncio
async def test_confirm_with_edited_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""品牌方修改后确认"""
create_resp = await _create_platform_rule(client, tenant_id, brand_id)
rule_id = create_resp.json()["id"]
edited_rules = {
"forbidden_words": ["绝对有效", "最强", "第一", "新增的违禁词"],
"restricted_words": [],
"duration": {"min_seconds": 10, "max_seconds": 120},
"content_requirements": ["必须展示产品"],
"other_rules": [],
}
confirm_resp = await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers={"X-Tenant-ID": tenant_id},
json={"parsed_rules": edited_rules},
)
assert confirm_resp.status_code == 200
data = confirm_resp.json()
assert "新增的违禁词" in data["parsed_rules"]["forbidden_words"]
assert data["parsed_rules"]["duration"]["min_seconds"] == 10
@pytest.mark.asyncio
async def test_confirm_deactivates_old_rule(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""确认新规则后旧的 active 规则变 inactive"""
# 创建并确认第一条规则
r1 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
rule1_id = r1.json()["id"]
await client.put(
f"/api/v1/rules/platform-rules/{rule1_id}/confirm",
headers={"X-Tenant-ID": tenant_id},
json={"parsed_rules": MOCK_PARSED_RULES},
)
# 创建并确认第二条规则(同品牌同平台)
r2 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
rule2_id = r2.json()["id"]
await client.put(
f"/api/v1/rules/platform-rules/{rule2_id}/confirm",
headers={"X-Tenant-ID": tenant_id},
json={"parsed_rules": MOCK_PARSED_RULES},
)
# 查询所有规则 — rule1 应该变 inactiverule2 应该 active
list_resp = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
headers={"X-Tenant-ID": tenant_id},
)
rules = list_resp.json()["items"]
rule1 = next(r for r in rules if r["id"] == rule1_id)
rule2 = next(r for r in rules if r["id"] == rule2_id)
assert rule1["status"] == "inactive"
assert rule2["status"] == "active"
@pytest.mark.asyncio
async def test_confirm_nonexistent_rule_returns_404(self, client: AsyncClient, tenant_id: str):
"""确认不存在的规则返回 404"""
resp = await client.put(
"/api/v1/rules/platform-rules/pr-nonexist/confirm",
headers={"X-Tenant-ID": tenant_id},
json={"parsed_rules": MOCK_PARSED_RULES},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_confirm_cross_tenant_returns_404(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""不同租户确认规则返回 404租户隔离"""
create_resp = await _create_platform_rule(client, tenant_id, brand_id)
rule_id = create_resp.json()["id"]
resp = await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers={"X-Tenant-ID": "other-tenant-xxx"},
json={"parsed_rules": MOCK_PARSED_RULES},
)
assert resp.status_code == 404
class TestBrandPlatformRuleList:
"""品牌方平台规则 — 列表查询"""
@pytest.mark.asyncio
async def test_list_empty_returns_200(self, client: AsyncClient, tenant_id: str):
"""没有规则时返回空列表"""
resp = await client.get(
"/api/v1/rules/platform-rules",
headers={"X-Tenant-ID": tenant_id},
)
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
@pytest.mark.asyncio
async def test_list_returns_created_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""创建规则后列表包含该规则"""
await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
resp = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}",
headers={"X-Tenant-ID": tenant_id},
)
data = resp.json()
assert data["total"] == 2
platforms = {r["platform"] for r in data["items"]}
assert platforms == {"douyin", "xiaohongshu"}
@pytest.mark.asyncio
async def test_list_filter_by_platform(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""按平台筛选"""
await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
resp = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
headers={"X-Tenant-ID": tenant_id},
)
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["platform"] == "douyin"
@pytest.mark.asyncio
async def test_list_filter_by_status(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""按状态筛选"""
r = await _create_platform_rule(client, tenant_id, brand_id)
rule_id = r.json()["id"]
# 确认一条
await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers={"X-Tenant-ID": tenant_id},
json={"parsed_rules": MOCK_PARSED_RULES},
)
# 再创建一条 draft
await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
# 只查 active
resp = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}&status=active",
headers={"X-Tenant-ID": tenant_id},
)
active_rules = resp.json()["items"]
assert all(r["status"] == "active" for r in active_rules)
# 只查 draft
resp2 = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}&status=draft",
headers={"X-Tenant-ID": tenant_id},
)
draft_rules = resp2.json()["items"]
assert all(r["status"] == "draft" for r in draft_rules)
@pytest.mark.asyncio
async def test_list_tenant_isolation(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""租户隔离:不同租户看不到彼此的规则"""
await _create_platform_rule(client, tenant_id, brand_id)
resp = await client.get(
"/api/v1/rules/platform-rules",
headers={"X-Tenant-ID": "another-tenant-yyy"},
)
assert resp.json()["total"] == 0
class TestBrandPlatformRuleDelete:
"""品牌方平台规则 — 删除"""
@pytest.mark.asyncio
async def test_delete_returns_204(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""删除规则返回 204"""
r = await _create_platform_rule(client, tenant_id, brand_id)
rule_id = r.json()["id"]
resp = await client.delete(
f"/api/v1/rules/platform-rules/{rule_id}",
headers={"X-Tenant-ID": tenant_id},
)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_delete_actually_removes(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""删除后列表中不再包含该规则"""
r = await _create_platform_rule(client, tenant_id, brand_id)
rule_id = r.json()["id"]
await client.delete(
f"/api/v1/rules/platform-rules/{rule_id}",
headers={"X-Tenant-ID": tenant_id},
)
resp = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}",
headers={"X-Tenant-ID": tenant_id},
)
ids = [r["id"] for r in resp.json()["items"]]
assert rule_id not in ids
@pytest.mark.asyncio
async def test_delete_nonexistent_returns_404(self, client: AsyncClient, tenant_id: str):
"""删除不存在的规则返回 404"""
resp = await client.delete(
"/api/v1/rules/platform-rules/pr-nonexist",
headers={"X-Tenant-ID": tenant_id},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_cross_tenant_returns_404(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""不同租户删除规则返回 404租户隔离"""
r = await _create_platform_rule(client, tenant_id, brand_id)
rule_id = r.json()["id"]
resp = await client.delete(
f"/api/v1/rules/platform-rules/{rule_id}",
headers={"X-Tenant-ID": "other-tenant-zzz"},
)
assert resp.status_code == 404
class TestBrandPlatformRuleLifecycle:
"""品牌方平台规则 — 完整生命周期"""
@pytest.mark.asyncio
async def test_full_lifecycle(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""完整流程: 上传解析 → 确认生效 → 重新上传 → 旧规则停用"""
headers = {"X-Tenant-ID": tenant_id}
# 1. 上传并解析
r1 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
assert r1.status_code == 201
rule1_id = r1.json()["id"]
assert r1.json()["status"] == "draft"
# 2. 确认生效
confirm_resp = await client.put(
f"/api/v1/rules/platform-rules/{rule1_id}/confirm",
headers=headers,
json={"parsed_rules": MOCK_PARSED_RULES},
)
assert confirm_resp.json()["status"] == "active"
# 3. 重新上传新规则
r2 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
rule2_id = r2.json()["id"]
assert r2.json()["status"] == "draft"
# 4. 确认新规则
await client.put(
f"/api/v1/rules/platform-rules/{rule2_id}/confirm",
headers=headers,
json={"parsed_rules": MOCK_PARSED_RULES},
)
# 5. 验证旧规则自动停用
list_resp = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
headers=headers,
)
rules = list_resp.json()["items"]
rule1 = next(r for r in rules if r["id"] == rule1_id)
rule2 = next(r for r in rules if r["id"] == rule2_id)
assert rule1["status"] == "inactive"
assert rule2["status"] == "active"
# 6. 删除旧规则
del_resp = await client.delete(
f"/api/v1/rules/platform-rules/{rule1_id}",
headers=headers,
)
assert del_resp.status_code == 204
# 7. 验证只剩新规则
final_resp = await client.get(
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
headers=headers,
)
assert final_resp.json()["total"] == 1
assert final_resp.json()["items"][0]["id"] == rule2_id