主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
423 lines
16 KiB
Python
423 lines
16 KiB
Python
"""
|
||
视频审核 API 测试 (TDD - 红色阶段)
|
||
测试覆盖: 视频上传、异步审核、审核结果、进度查询
|
||
"""
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
from app.schemas.review import (
|
||
VideoReviewSubmitResponse,
|
||
VideoReviewProgressResponse,
|
||
VideoReviewResultResponse,
|
||
TaskStatus,
|
||
RiskLevel,
|
||
ViolationType,
|
||
)
|
||
|
||
|
||
class TestVideoUpload:
|
||
"""视频上传"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_video_url_returns_202(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""提交视频 URL 返回 202 Accepted(异步处理)"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
assert response.status_code == 202
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_video_returns_review_id(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""提交视频返回审核任务 ID"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = VideoReviewSubmitResponse.model_validate(data)
|
||
assert parsed.review_id
|
||
assert parsed.status == TaskStatus.PENDING
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_video_validates_url(self, client: AsyncClient, tenant_id: str, brand_id: str, creator_id: str):
|
||
"""校验视频 URL 格式"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": "invalid-url",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_video_validates_platform(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""校验投放平台"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "invalid_platform",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
|
||
class TestReviewProgress:
|
||
"""审核进度查询"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_progress_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""查询进度返回 200"""
|
||
headers = {"X-Tenant-ID": tenant_id}
|
||
# 先提交视频
|
||
submit_resp = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers=headers,
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
review_id = submit_resp.json()["review_id"]
|
||
|
||
# 查询进度
|
||
response = await client.get(
|
||
f"/api/v1/videos/review/{review_id}/progress",
|
||
headers=headers,
|
||
)
|
||
assert response.status_code == 200
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_progress_returns_status(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""查询进度返回状态信息"""
|
||
headers = {"X-Tenant-ID": tenant_id}
|
||
submit_resp = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers=headers,
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
review_id = submit_resp.json()["review_id"]
|
||
|
||
response = await client.get(
|
||
f"/api/v1/videos/review/{review_id}/progress",
|
||
headers=headers,
|
||
)
|
||
data = response.json()
|
||
parsed = VideoReviewProgressResponse.model_validate(data)
|
||
|
||
assert parsed.review_id == review_id
|
||
assert parsed.status in [TaskStatus.PENDING, TaskStatus.PROCESSING]
|
||
assert 0 <= parsed.progress <= 100
|
||
assert isinstance(parsed.current_step, str) and parsed.current_step
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_progress_shows_current_step(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""进度显示当前处理步骤"""
|
||
headers = {"X-Tenant-ID": tenant_id}
|
||
submit_resp = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers=headers,
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
review_id = submit_resp.json()["review_id"]
|
||
|
||
response = await client.get(
|
||
f"/api/v1/videos/review/{review_id}/progress",
|
||
headers=headers,
|
||
)
|
||
data = response.json()
|
||
parsed = VideoReviewProgressResponse.model_validate(data)
|
||
|
||
assert isinstance(parsed.current_step, str)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_progress_nonexistent_returns_404(self, client: AsyncClient, tenant_id: str):
|
||
"""查询不存在的审核任务返回 404"""
|
||
response = await client.get(
|
||
"/api/v1/videos/review/nonexistent-id/progress",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
)
|
||
assert response.status_code == 404
|
||
|
||
|
||
class TestReviewResult:
|
||
"""审核结果查询"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_result_processing_returns_202(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""查询处理中的审核返回 202 并返回进度结构"""
|
||
headers = {"X-Tenant-ID": tenant_id}
|
||
submit_resp = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers=headers,
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
review_id = submit_resp.json()["review_id"]
|
||
|
||
response = await client.get(
|
||
f"/api/v1/videos/review/{review_id}/result",
|
||
headers=headers,
|
||
)
|
||
assert response.status_code == 202
|
||
parsed = VideoReviewProgressResponse.model_validate(response.json())
|
||
assert parsed.review_id == review_id
|
||
assert parsed.status in [TaskStatus.PENDING, TaskStatus.PROCESSING]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_result_nonexistent_returns_404(self, client: AsyncClient, tenant_id: str):
|
||
"""查询不存在的审核任务返回 404"""
|
||
response = await client.get(
|
||
"/api/v1/videos/review/nonexistent-id/result",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
)
|
||
assert response.status_code == 404
|
||
|
||
|
||
class TestViolationStructure:
|
||
"""违规项结构验证(使用 Mock 数据)"""
|
||
|
||
@pytest.fixture
|
||
def mock_completed_review(self):
|
||
"""Mock 已完成的审核结果"""
|
||
return {
|
||
"review_id": "test-review-001",
|
||
"status": "completed",
|
||
"score": 65,
|
||
"summary": "发现 2 处违规",
|
||
"violations": [
|
||
{
|
||
"type": "forbidden_word",
|
||
"content": "最好",
|
||
"timestamp": 15,
|
||
"timestamp_end": 17,
|
||
"severity": "high",
|
||
"source": "speech",
|
||
"suggestion": "建议删除或替换",
|
||
},
|
||
{
|
||
"type": "competitor_logo",
|
||
"content": "竞品A",
|
||
"timestamp": 45,
|
||
"timestamp_end": 48,
|
||
"severity": "high",
|
||
"source": "visual",
|
||
"suggestion": "请移除画面中的竞品露出",
|
||
},
|
||
]
|
||
}
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_violation_has_timestamp(self, mock_completed_review):
|
||
"""违规项包含时间戳"""
|
||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||
for violation in parsed.violations:
|
||
assert violation.timestamp is not None
|
||
assert violation.timestamp_end is not None
|
||
assert violation.timestamp_end >= violation.timestamp
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_violation_has_risk_level(self, mock_completed_review):
|
||
"""违规项包含风险等级"""
|
||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||
for violation in parsed.violations:
|
||
assert violation.severity.value in ["high", "medium", "low"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_violation_has_source(self, mock_completed_review):
|
||
"""违规项包含来源(语音/画面/字幕)"""
|
||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||
for violation in parsed.violations:
|
||
assert violation.source is not None
|
||
assert violation.source.value in ["speech", "visual", "subtitle", "text"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_violation_has_suggestion(self, mock_completed_review):
|
||
"""违规项包含修改建议"""
|
||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||
for violation in parsed.violations:
|
||
assert isinstance(violation.suggestion, str)
|
||
assert violation.suggestion
|
||
|
||
|
||
class TestRiskLevelClassification:
|
||
"""风险等级分类逻辑"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_legal_violation_is_high_risk(self):
|
||
"""法律违规(广告法极限词)标记为高风险"""
|
||
from app.services.risk import classify_risk_level
|
||
assert classify_risk_level(ViolationType.FORBIDDEN_WORD) == RiskLevel.HIGH
|
||
assert classify_risk_level(ViolationType.EFFICACY_CLAIM) == RiskLevel.HIGH
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_platform_violation_is_medium_risk(self):
|
||
"""平台规则违规标记为中风险"""
|
||
from app.services.risk import classify_risk_level
|
||
assert classify_risk_level(ViolationType.COMPETITOR_LOGO) == RiskLevel.MEDIUM
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_brand_guideline_violation_is_low_risk(self):
|
||
"""品牌规范违规标记为低风险"""
|
||
from app.services.risk import classify_risk_level
|
||
assert classify_risk_level(ViolationType.MENTION_MISSING) == RiskLevel.LOW
|
||
|
||
|
||
class TestViolationDetection:
|
||
"""违规检测场景"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_competitor_logo(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""检测竞品 Logo - 提交成功并返回 review_id"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
"competitors": ["competitor-brand-A", "competitor-brand-B"],
|
||
}
|
||
)
|
||
assert response.status_code == 202
|
||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||
assert parsed.review_id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_forbidden_word_in_speech(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""检测口播中的违禁词(ASR)"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
assert response.status_code == 202
|
||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||
assert parsed.review_id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_forbidden_word_in_subtitle(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""检测字幕中的违禁词(OCR)"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
}
|
||
)
|
||
assert response.status_code == 202
|
||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||
assert parsed.review_id
|
||
|
||
|
||
class TestDurationAndFrequency:
|
||
"""时长与频次校验 (F-45)"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_check_product_display_duration(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""校验产品同框时长 - 请求参数被接受"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
"requirements": {
|
||
"min_product_display_seconds": 5,
|
||
}
|
||
}
|
||
)
|
||
assert response.status_code == 202
|
||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||
assert parsed.review_id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_check_brand_mention_frequency(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||
"""校验品牌提及频次 - 请求参数被接受"""
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": video_url,
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
"requirements": {
|
||
"min_brand_mentions": 3,
|
||
}
|
||
}
|
||
)
|
||
assert response.status_code == 202
|
||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||
assert parsed.review_id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_duration_requirement_accepted(self, client: AsyncClient, tenant_id: str, brand_id: str, creator_id: str):
|
||
"""时长要求参数被正确接受"""
|
||
# 提交带时长要求的审核请求
|
||
response = await client.post(
|
||
"/api/v1/videos/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"video_url": "https://example.com/short_display.mp4",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"creator_id": creator_id,
|
||
"requirements": {
|
||
"min_product_display_seconds": 10,
|
||
}
|
||
}
|
||
)
|
||
|
||
# 请求应该被接受
|
||
assert response.status_code == 202
|
||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||
assert parsed.review_id
|