feat: 完善代理商端业务逻辑与前后端框架

主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+1
View File
@@ -0,0 +1 @@
"""测试模块"""
+464
View File
@@ -0,0 +1,464 @@
"""
pytest 配置和 fixtures
测试覆盖: 数据库会话、HTTP 客户端、Mock 数据
使用 app.dependency_overrides 实现测试隔离(支持并行测试)
"""
import pytest
import asyncio
import uuid
from typing import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.config import settings
from app.database import get_db
from app.models.base import Base
from app.services.health import (
MockHealthChecker,
get_health_checker,
)
@pytest.fixture(scope="session")
def event_loop():
"""创建事件循环(session 级别)"""
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
yield loop
loop.close()
# ==================== 数据库测试 Fixtures ====================
@pytest.fixture(scope="function")
async def test_db_engine():
"""创建测试数据库引擎(使用 SQLite 内存数据库)"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
future=True,
)
# 创建所有表
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
# 清理
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture(scope="function")
async def test_db_session(test_db_engine):
"""创建测试数据库会话"""
async_session_factory = sessionmaker(
test_db_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with async_session_factory() as session:
yield session
@pytest.fixture
async def client(test_db_session) -> AsyncGenerator[AsyncClient, None]:
"""
创建异步测试客户端(使用测试数据库)
Yields:
AsyncClient: httpx 异步客户端
"""
# 覆盖数据库依赖
async def override_get_db():
yield test_db_session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
# 每个测试结束后清理 dependency_overrides
app.dependency_overrides.clear()
@pytest.fixture
async def client_no_db() -> AsyncGenerator[AsyncClient, None]:
"""
创建异步测试客户端(不使用数据库,用于简单测试)
Yields:
AsyncClient: httpx 异步客户端
"""
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
def mock_health_checker(client: AsyncClient):
"""
创建 Mock 健康检查器(所有依赖健康)
使用 FastAPI dependency_overrides 实现隔离
Yields:
MockHealthChecker: mock 实例
"""
checker = MockHealthChecker(database_healthy=True, redis_healthy=True)
app.dependency_overrides[get_health_checker] = lambda: checker
yield checker
# 清理由 client fixture 统一处理
@pytest.fixture
def mock_unhealthy_db_checker(client: AsyncClient):
"""
创建 Mock 健康检查器(数据库不健康)
Yields:
MockHealthChecker: mock 实例
"""
checker = MockHealthChecker(database_healthy=False, redis_healthy=True)
app.dependency_overrides[get_health_checker] = lambda: checker
yield checker
@pytest.fixture
def mock_unhealthy_redis_checker(client: AsyncClient):
"""
创建 Mock 健康检查器(Redis 不健康)
Yields:
MockHealthChecker: mock 实例
"""
checker = MockHealthChecker(database_healthy=True, redis_healthy=False)
app.dependency_overrides[get_health_checker] = lambda: checker
yield checker
@pytest.fixture
def mock_all_unhealthy_checker(client: AsyncClient):
"""
创建 Mock 健康检查器(所有依赖不健康)
Yields:
MockHealthChecker: mock 实例
"""
checker = MockHealthChecker(database_healthy=False, redis_healthy=False)
app.dependency_overrides[get_health_checker] = lambda: checker
yield checker
@pytest.fixture
def app_settings():
"""
获取应用配置(用于测试断言)
Returns:
Settings: 应用配置实例
"""
return settings
# ==================== 通用测试数据 Fixtures ====================
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
@pytest.fixture
def tenant_id() -> str:
return _unique("tenant")
@pytest.fixture
def brand_id() -> str:
return _unique("brand")
@pytest.fixture
def other_brand_id() -> str:
return _unique("brand")
@pytest.fixture
def creator_id() -> str:
return _unique("creator")
@pytest.fixture
def influencer_id() -> str:
return _unique("influencer")
@pytest.fixture
def applicant_id() -> str:
return _unique("applicant")
@pytest.fixture
def approver_id() -> str:
return _unique("approver")
@pytest.fixture
def video_url() -> str:
return f"https://example.com/video-{uuid.uuid4().hex[:8]}.mp4"
@pytest.fixture
def forbidden_word() -> str:
return f"测试违禁词-{uuid.uuid4().hex[:6]}"
@pytest.fixture
def whitelist_term() -> str:
return f"品牌专属词-{uuid.uuid4().hex[:6]}"
@pytest.fixture
def competitor_name() -> str:
return f"竞品-{uuid.uuid4().hex[:6]}"
# ==================== 集成测试 Fixtures ====================
# 使用 testcontainers 运行真实依赖,标记为 integration
def _is_docker_available() -> bool:
"""检查 Docker 是否可用"""
import subprocess
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
return False
# 在模块加载时检查一次 Docker 可用性
_docker_available = None
def docker_available() -> bool:
"""获取 Docker 可用性(缓存结果)"""
global _docker_available
if _docker_available is None:
_docker_available = _is_docker_available()
return _docker_available
@pytest.fixture(scope="session")
def postgres_container():
"""
启动 PostgreSQL 容器(集成测试用)
需要 Docker 运行
Yields:
PostgresContainer: 容器实例
"""
pytest.importorskip("testcontainers")
if not docker_available():
pytest.skip("Docker is not available")
from testcontainers.postgres import PostgresContainer
with PostgresContainer("postgres:15-alpine") as postgres:
yield postgres
@pytest.fixture(scope="session")
def redis_container():
"""
启动 Redis 容器(集成测试用)
需要 Docker 运行
Yields:
RedisContainer: 容器实例
"""
pytest.importorskip("testcontainers")
if not docker_available():
pytest.skip("Docker is not available")
from testcontainers.redis import RedisContainer
with RedisContainer("redis:7-alpine") as redis:
yield redis
# ==================== Mock 数据 Fixtures ====================
@pytest.fixture
def mock_ai_response():
"""
AI 审核响应 mock 数据
Returns:
dict: 模拟的 AI 审核结果
"""
return {
"violations": [],
"score": 95,
"summary": "内容合规",
"details": {
"forbidden_words": [],
"logo_detected": True,
"duration_valid": True,
}
}
@pytest.fixture
def mock_ai_violation_response():
"""
AI 审核违规响应 mock 数据
Returns:
dict: 模拟的违规审核结果
"""
return {
"violations": [
{
"type": "forbidden_word",
"content": "最好",
"position": {"start": 10, "end": 12},
"severity": "medium",
"suggestion": "建议删除或替换为其他词汇",
}
],
"score": 65,
"summary": "发现1处违规",
"details": {
"forbidden_words": ["最好"],
"logo_detected": True,
"duration_valid": True,
}
}
@pytest.fixture
def sample_video_metadata():
"""
示例视频元数据
Returns:
dict: 视频元数据
"""
return {
"id": "video-001",
"title": "测试视频",
"duration": 30,
"resolution": "1080p",
"creator_id": "creator-001",
"platform": "douyin",
}
@pytest.fixture
def sample_task_data():
"""
示例审核任务数据
Returns:
dict: 任务数据
"""
return {
"video_url": "https://example.com/video.mp4",
"platform": "douyin",
"creator_id": "creator-001",
"priority": "normal",
"rules": ["ad_law", "platform_rules"],
}
# ==================== AI 配置相关 Fixtures ====================
@pytest.fixture
def mock_ai_models_response():
"""Mock 模型列表响应"""
return {
"success": True,
"models": {
"text": [
{"id": "gpt-4o", "name": "GPT-4o"},
{"id": "claude-3-opus", "name": "Claude 3 Opus"},
],
"vision": [
{"id": "gpt-4o", "name": "GPT-4o"},
{"id": "qwen-vl-max", "name": "Qwen VL Max"},
],
"audio": [
{"id": "whisper-1", "name": "Whisper"},
{"id": "whisper-large-v3", "name": "Whisper Large V3"},
],
},
}
@pytest.fixture
def mock_connection_test_success():
"""Mock 连接测试成功响应"""
return {
"success": True,
"results": {
"text": {"success": True, "latency_ms": 342, "model": "gpt-4o"},
"vision": {"success": True, "latency_ms": 528, "model": "gpt-4o"},
"audio": {"success": True, "latency_ms": 215, "model": "whisper-1"},
},
"message": "所有模型连接成功",
}
@pytest.fixture
def mock_connection_test_partial_fail():
"""Mock 连接测试部分失败响应"""
return {
"success": False,
"results": {
"text": {"success": True, "latency_ms": 342, "model": "gpt-4o"},
"vision": {"success": True, "latency_ms": 528, "model": "gpt-4o"},
"audio": {"success": False, "error": "Model not found", "model": "invalid-model"},
},
"message": "1 个模型连接失败,请检查模型名称或 API 权限",
}
# ==================== AI 客户端 Mock Fixtures ====================
@pytest.fixture
def mock_ai_client():
"""创建 Mock AI 客户端"""
client = MagicMock()
client.chat_completion = AsyncMock(return_value=MagicMock(
content="[]",
model="gpt-4o",
usage={"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
finish_reason="stop",
))
client.vision_analysis = AsyncMock(return_value=MagicMock(
content="无竞品 Logo",
model="gpt-4o",
usage={"prompt_tokens": 200, "completion_tokens": 50, "total_tokens": 250},
finish_reason="stop",
))
client.test_connection = AsyncMock(return_value=MagicMock(
success=True,
latency_ms=100,
error=None,
))
client.close = AsyncMock()
return client
+345
View File
@@ -0,0 +1,345 @@
"""
AI 服务配置 API 测试 (TDD - 红色阶段)
测试覆盖: 配置管理、模型列表、连通性测试
"""
import pytest
from httpx import AsyncClient
from app.schemas.ai_config import (
AIConfigResponse,
ConnectionTestResponse,
ModelsListResponse,
)
class TestGetAIConfig:
"""获取 AI 配置"""
@pytest.mark.asyncio
async def test_get_config_unconfigured_returns_404(self, client: AsyncClient, tenant_id: str):
"""未配置时返回 404"""
response = await client.get(
"/api/v1/ai-config",
headers={"X-Tenant-ID": tenant_id},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_config_returns_200(self, client: AsyncClient, tenant_id: str):
"""已配置时返回 200"""
headers = {"X-Tenant-ID": tenant_id}
# 先创建配置
await client.put(
"/api/v1/ai-config",
headers=headers,
json={
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-test-key-12345678",
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
},
)
response = await client.get("/api/v1/ai-config", headers=headers)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_config_returns_masked_api_key(self, client: AsyncClient, tenant_id: str):
"""API Key 应该脱敏"""
headers = {"X-Tenant-ID": tenant_id}
# 先创建配置
await client.put(
"/api/v1/ai-config",
headers=headers,
json={
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-test-key-12345678",
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
},
)
response = await client.get("/api/v1/ai-config", headers=headers)
data = response.json()
parsed = AIConfigResponse.model_validate(data)
# API Key 应该脱敏,包含 ****
assert "****" in parsed.api_key_masked
@pytest.mark.asyncio
async def test_get_config_returns_models(self, client: AsyncClient, tenant_id: str):
"""返回三个模型配置"""
headers = {"X-Tenant-ID": tenant_id}
# 先创建配置
await client.put(
"/api/v1/ai-config",
headers=headers,
json={
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-test-key-12345678",
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
},
)
response = await client.get("/api/v1/ai-config", headers=headers)
data = response.json()
parsed = AIConfigResponse.model_validate(data)
assert parsed.models.text
assert parsed.models.vision
assert parsed.models.audio
class TestUpdateAIConfig:
"""更新 AI 配置"""
@pytest.mark.asyncio
async def test_update_config_returns_200(self, client: AsyncClient, tenant_id: str):
"""更新配置返回 200"""
response = await client.put(
"/api/v1/ai-config",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": "oneapi",
"base_url": "https://oneapi.example.com",
"api_key": "sk-test-key-12345678",
"models": {
"text": "gpt-4o",
"vision": "gpt-4o",
"audio": "whisper-1",
},
"parameters": {
"temperature": 0.7,
"max_tokens": 2000,
},
},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_update_config_validates_provider(self, client: AsyncClient, tenant_id: str):
"""校验提供商类型"""
response = await client.put(
"/api/v1/ai-config",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": "invalid_provider",
"base_url": "https://example.com",
"api_key": "sk-test",
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_config_validates_models_required(self, client: AsyncClient, tenant_id: str):
"""三个模型都必填"""
response = await client.put(
"/api/v1/ai-config",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": "oneapi",
"base_url": "https://example.com",
"api_key": "sk-test",
"models": {"text": "gpt-4o"}, # 缺少 vision 和 audio
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_config_persists(self, client: AsyncClient, tenant_id: str):
"""配置更新后可查询"""
headers = {"X-Tenant-ID": tenant_id}
# 更新
await client.put(
"/api/v1/ai-config",
headers=headers,
json={
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-test-persist-12345678",
"models": {
"text": "gpt-4o-mini",
"vision": "gpt-4o",
"audio": "whisper-1",
},
},
)
# 查询
response = await client.get("/api/v1/ai-config", headers=headers)
data = response.json()
parsed = AIConfigResponse.model_validate(data)
assert parsed.provider == "openai"
assert parsed.models.text == "gpt-4o-mini"
assert parsed.is_configured is True
class TestGetModels:
"""获取可用模型列表"""
@pytest.mark.asyncio
async def test_get_models_returns_200(self, client: AsyncClient, tenant_id: str):
"""获取模型列表返回 200"""
response = await client.post(
"/api/v1/ai-config/models",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": "oneapi",
"base_url": "https://oneapi.example.com",
"api_key": "sk-test-key",
},
)
# 可能返回 200(成功)或 502(连接失败)
assert response.status_code in [200, 502]
@pytest.mark.asyncio
async def test_get_models_returns_categorized_list(self, client: AsyncClient, mock_ai_models_response):
"""返回按类型分类的模型列表"""
# 使用 mock 响应
data = mock_ai_models_response
parsed = ModelsListResponse.model_validate(data)
assert "text" in parsed.models
assert "vision" in parsed.models
assert "audio" in parsed.models
assert isinstance(parsed.models["text"], list)
class TestConnectionTest:
"""连通性测试"""
@pytest.mark.asyncio
async def test_connection_test_returns_200(self, client: AsyncClient, tenant_id: str):
"""测试连接返回 200"""
response = await client.post(
"/api/v1/ai-config/test",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": "oneapi",
"base_url": "https://oneapi.example.com",
"api_key": "sk-test-key",
"models": {
"text": "gpt-4o",
"vision": "gpt-4o",
"audio": "whisper-1",
},
},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_connection_test_returns_all_results(self, client: AsyncClient, tenant_id: str):
"""返回三个模型的测试结果"""
response = await client.post(
"/api/v1/ai-config/test",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": "oneapi",
"base_url": "https://oneapi.example.com",
"api_key": "sk-test-key",
"models": {
"text": "gpt-4o",
"vision": "gpt-4o",
"audio": "whisper-1",
},
},
)
data = response.json()
parsed = ConnectionTestResponse.model_validate(data)
assert "text" in parsed.results
assert "vision" in parsed.results
assert "audio" in parsed.results
assert isinstance(parsed.message, str)
@pytest.mark.asyncio
async def test_connection_test_includes_latency(self, client: AsyncClient, mock_connection_test_success):
"""成功时包含延迟信息"""
data = mock_connection_test_success
parsed = ConnectionTestResponse.model_validate(data)
for model_type, result in parsed.results.items():
if result.success:
assert result.latency_ms is not None
assert result.latency_ms > 0
@pytest.mark.asyncio
async def test_connection_test_includes_error_message(self, client: AsyncClient, mock_connection_test_partial_fail):
"""失败时包含错误信息"""
data = mock_connection_test_partial_fail
parsed = ConnectionTestResponse.model_validate(data)
assert parsed.success is False
# 至少有一个失败
failed = [r for r in parsed.results.values() if not r.success]
assert len(failed) > 0
assert failed[0].error is not None
class TestMultiTenantIsolation:
"""多租户隔离"""
@pytest.mark.asyncio
async def test_config_isolated_between_tenants(self, client: AsyncClient, tenant_id: str, other_brand_id: str):
"""不同租户配置隔离"""
# 为 tenant_id 配置
await client.put(
"/api/v1/ai-config",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-brand-a-key",
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
},
)
# 为 other_brand_id 配置
await client.put(
"/api/v1/ai-config",
headers={"X-Tenant-ID": other_brand_id},
json={
"provider": "anthropic",
"base_url": "https://api.anthropic.com/v1",
"api_key": "sk-brand-b-key",
"models": {"text": "claude-3-opus", "vision": "claude-3-opus", "audio": "whisper-1"},
},
)
# 查询 tenant_id
resp_a = await client.get("/api/v1/ai-config", headers={"X-Tenant-ID": tenant_id})
data_a = resp_a.json()
# 查询 other_brand_id
resp_b = await client.get("/api/v1/ai-config", headers={"X-Tenant-ID": other_brand_id})
data_b = resp_b.json()
# 验证隔离
assert data_a["provider"] == "openai"
assert data_b["provider"] == "anthropic"
class TestProviderSupport:
"""提供商支持"""
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", [
"oneapi",
"openrouter",
"anthropic",
"openai",
"deepseek",
])
async def test_supported_providers(self, client: AsyncClient, tenant_id: str, provider: str):
"""支持的提供商类型"""
response = await client.put(
"/api/v1/ai-config",
headers={"X-Tenant-ID": tenant_id},
json={
"provider": provider,
"base_url": f"https://api.{provider}.com/v1",
"api_key": "sk-test-key",
"models": {"text": "test-model", "vision": "test-model", "audio": "test-model"},
},
)
assert response.status_code == 200
+158
View File
@@ -0,0 +1,158 @@
"""
健康检查 API 测试
测试覆盖: /health, /health/ready, /health/live
使用依赖注入 mock 健康检查器
"""
import pytest
from httpx import AsyncClient
from app.config import Settings
class TestHealthCheck:
"""健康检查端点测试"""
# ==================== /health 测试 ====================
@pytest.mark.asyncio
async def test_health_check_returns_200(self, client: AsyncClient):
"""健康检查返回 200 状态码"""
response = await client.get("/api/v1/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_health_check_response_structure(self, client: AsyncClient):
"""健康检查返回正确的响应结构"""
response = await client.get("/api/v1/health")
data = response.json()
assert "status" in data
assert "service" in data
assert "version" in data
@pytest.mark.asyncio
async def test_health_check_uses_settings(
self, client: AsyncClient, app_settings: Settings
):
"""健康检查使用 settings 中的配置"""
response = await client.get("/api/v1/health")
data = response.json()
assert data["status"] == "healthy"
# 使用 settings 中的值,而非硬编码
assert data["service"] == app_settings.APP_NAME
assert data["version"] == app_settings.APP_VERSION
# ==================== /health/ready 测试 ====================
@pytest.mark.asyncio
async def test_readiness_check_returns_200(
self, client: AsyncClient, mock_health_checker
):
"""就绪检查返回 200 状态码"""
response = await client.get("/api/v1/health/ready")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_readiness_check_ready_when_all_healthy(
self, client: AsyncClient, mock_health_checker
):
"""所有依赖健康时返回 ready=true"""
response = await client.get("/api/v1/health/ready")
data = response.json()
assert data["ready"] is True
assert data["checks"]["database"] is True
assert data["checks"]["redis"] is True
@pytest.mark.asyncio
async def test_readiness_check_not_ready_when_db_unhealthy(
self, client: AsyncClient, mock_unhealthy_db_checker
):
"""数据库不健康时返回 ready=false"""
response = await client.get("/api/v1/health/ready")
data = response.json()
assert data["ready"] is False
assert data["checks"]["database"] is False
assert data["checks"]["redis"] is True
@pytest.mark.asyncio
async def test_readiness_check_not_ready_when_redis_unhealthy(
self, client: AsyncClient, mock_unhealthy_redis_checker
):
"""Redis 不健康时返回 ready=false"""
response = await client.get("/api/v1/health/ready")
data = response.json()
assert data["ready"] is False
assert data["checks"]["database"] is True
assert data["checks"]["redis"] is False
@pytest.mark.asyncio
async def test_readiness_check_not_ready_when_all_unhealthy(
self, client: AsyncClient, mock_all_unhealthy_checker
):
"""所有依赖不健康时返回 ready=false"""
response = await client.get("/api/v1/health/ready")
data = response.json()
assert data["ready"] is False
assert data["checks"]["database"] is False
assert data["checks"]["redis"] is False
@pytest.mark.asyncio
async def test_readiness_check_returns_checks_detail(
self, client: AsyncClient, mock_health_checker
):
"""就绪检查返回详细的检查结果"""
response = await client.get("/api/v1/health/ready")
data = response.json()
assert "checks" in data
assert "database" in data["checks"]
assert "redis" in data["checks"]
# ==================== /health/live 测试 ====================
@pytest.mark.asyncio
async def test_liveness_check_returns_200(self, client: AsyncClient):
"""存活检查返回 200 状态码"""
response = await client.get("/api/v1/health/live")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_liveness_check_always_alive(self, client: AsyncClient):
"""存活检查始终返回 alive=true(只检查进程存活)"""
response = await client.get("/api/v1/health/live")
data = response.json()
# liveness 不依赖外部服务,只要进程活着就返回 true
assert data["alive"] is True
class TestRootEndpoint:
"""根路径测试"""
@pytest.mark.asyncio
async def test_root_returns_200(self, client: AsyncClient):
"""根路径返回 200 状态码"""
response = await client.get("/")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_root_response_structure(self, client: AsyncClient):
"""根路径返回正确的响应结构"""
response = await client.get("/")
data = response.json()
assert "message" in data
assert "version" in data
@pytest.mark.asyncio
async def test_root_uses_settings(
self, client: AsyncClient, app_settings: Settings
):
"""根路径使用 settings 中的应用名称"""
response = await client.get("/")
data = response.json()
# 验证响应中包含 settings.APP_NAME
assert app_settings.APP_NAME in data["message"]
+241
View File
@@ -0,0 +1,241 @@
"""
健康检查 API 集成测试
使用 testcontainers 运行真实 PostgreSQL 和 Redis
运行: pytest tests/test_health_integration.py -m integration
"""
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.main import app
from app.services.health import get_health_checker, DefaultHealthChecker
class RealHealthChecker:
"""
真实健康检查实现(用于集成测试)
正确处理资源释放,支持连接超时配置
"""
# 测试用短超时(秒),避免无效主机导致长时间等待
DEFAULT_CONNECT_TIMEOUT = 2
def __init__(self, db_url: str, redis_url: str, connect_timeout: float = DEFAULT_CONNECT_TIMEOUT):
self._db_url = db_url
self._redis_url = redis_url
self._connect_timeout = connect_timeout
async def check_database(self) -> bool:
"""检查数据库连接(确保资源释放)"""
engine = None
try:
engine = create_async_engine(
self._db_url,
connect_args={"timeout": self._connect_timeout}
)
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return True
except Exception:
return False
finally:
# 确保 engine 被正确释放
if engine is not None:
await engine.dispose()
async def check_redis(self) -> bool:
"""检查 Redis 连接(确保资源释放)"""
client = None
try:
import redis.asyncio as aioredis
client = aioredis.from_url(
self._redis_url,
socket_connect_timeout=self._connect_timeout
)
await client.ping()
return True
except Exception:
return False
finally:
# 确保 client 被正确释放
if client is not None:
try:
await client.aclose()
except Exception:
pass
async def check_all(self) -> dict[str, bool]:
"""检查所有依赖"""
return {
"database": await self.check_database(),
"redis": await self.check_redis(),
}
@pytest.mark.integration
class TestHealthCheckIntegration:
"""健康检查集成测试(需要 Docker"""
@pytest.mark.asyncio
async def test_readiness_with_real_postgres(self, postgres_container):
"""使用真实 PostgreSQL 测试就绪检查"""
# 获取容器连接信息
host = postgres_container.get_container_host_ip()
port = postgres_container.get_exposed_port(5432)
db_url = f"postgresql+asyncpg://test:test@{host}:{port}/test"
# 创建真实健康检查器
checker = RealHealthChecker(db_url=db_url, redis_url="redis://invalid:6379")
# 注入到 app
app.dependency_overrides[get_health_checker] = lambda: checker
try:
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/health/ready")
data = response.json()
# 数据库应该健康
assert data["checks"]["database"] is True
# Redis 连接失败(无效地址)
assert data["checks"]["redis"] is False
# 整体不就绪
assert data["ready"] is False
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_readiness_with_real_redis(self, redis_container):
"""使用真实 Redis 测试就绪检查"""
# 获取容器连接信息
host = redis_container.get_container_host_ip()
port = redis_container.get_exposed_port(6379)
redis_url = f"redis://{host}:{port}"
# 创建真实健康检查器
checker = RealHealthChecker(
db_url="postgresql+asyncpg://invalid:invalid@invalid:5432/invalid",
redis_url=redis_url
)
# 注入到 app
app.dependency_overrides[get_health_checker] = lambda: checker
try:
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/health/ready")
data = response.json()
# 数据库连接失败(无效地址)
assert data["checks"]["database"] is False
# Redis 应该健康
assert data["checks"]["redis"] is True
# 整体不就绪
assert data["ready"] is False
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_readiness_with_all_real_deps(
self, postgres_container, redis_container
):
"""使用真实 PostgreSQL 和 Redis 测试就绪检查"""
# PostgreSQL 连接信息
pg_host = postgres_container.get_container_host_ip()
pg_port = postgres_container.get_exposed_port(5432)
db_url = f"postgresql+asyncpg://test:test@{pg_host}:{pg_port}/test"
# Redis 连接信息
redis_host = redis_container.get_container_host_ip()
redis_port = redis_container.get_exposed_port(6379)
redis_url = f"redis://{redis_host}:{redis_port}"
# 创建真实健康检查器
checker = RealHealthChecker(db_url=db_url, redis_url=redis_url)
# 注入到 app
app.dependency_overrides[get_health_checker] = lambda: checker
try:
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/health/ready")
data = response.json()
# 所有依赖应该健康
assert data["checks"]["database"] is True
assert data["checks"]["redis"] is True
# 整体就绪
assert data["ready"] is True
finally:
app.dependency_overrides.clear()
@pytest.mark.integration
class TestDatabaseConnectionIntegration:
"""数据库连接集成测试"""
@pytest.mark.asyncio
async def test_database_query_execution(self, postgres_container):
"""测试真实数据库查询执行"""
host = postgres_container.get_container_host_ip()
port = postgres_container.get_exposed_port(5432)
db_url = f"postgresql+asyncpg://test:test@{host}:{port}/test"
engine = create_async_engine(db_url)
try:
async with engine.connect() as conn:
result = await conn.execute(text("SELECT 1 as value"))
row = result.fetchone()
assert row is not None
assert row[0] == 1
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_database_connection_failure(self):
"""测试数据库连接失败场景"""
invalid_url = "postgresql+asyncpg://invalid:invalid@invalid:5432/invalid"
checker = RealHealthChecker(db_url=invalid_url, redis_url="redis://invalid:6379")
result = await checker.check_database()
assert result is False
@pytest.mark.integration
class TestDefaultHealthCheckerIntegration:
"""DefaultHealthChecker 集成测试"""
@pytest.mark.asyncio
async def test_default_checker_with_real_postgres(self, postgres_container):
"""测试 DefaultHealthChecker 使用真实 PostgreSQL"""
host = postgres_container.get_container_host_ip()
port = postgres_container.get_exposed_port(5432)
db_url = f"postgresql+asyncpg://test:test@{host}:{port}/test"
engine = create_async_engine(db_url)
try:
# 使用短超时避免无效主机长时间等待
checker = DefaultHealthChecker(
db_engine=engine,
redis_url="redis://invalid:6379",
connect_timeout=2
)
result = await checker.check_database()
assert result is True
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_default_checker_with_real_redis(self, redis_container):
"""测试 DefaultHealthChecker 使用真实 Redis"""
host = redis_container.get_container_host_ip()
port = redis_container.get_exposed_port(6379)
redis_url = f"redis://{host}:{port}"
checker = DefaultHealthChecker(db_engine=None, redis_url=redis_url)
result = await checker.check_redis()
assert result is True
+62
View File
@@ -0,0 +1,62 @@
"""
一致性指标 API 测试 (TDD - 红色阶段)
双轨制: Rolling 30 Days + Snapshot 周/月
维度: Influencer + Rule Type
"""
import pytest
from httpx import AsyncClient
from app.schemas.review import ConsistencyMetricsResponse, ConsistencyWindow, ViolationType
class TestConsistencyMetrics:
"""一致性指标查询"""
@pytest.mark.asyncio
async def test_requires_influencer_id(self, client: AsyncClient):
"""缺少 influencer_id 返回 422"""
response = await client.get("/api/v1/metrics/consistency?window=rolling_30d")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_rolling_30d_returns_metrics(self, client: AsyncClient, influencer_id: str):
"""Rolling 30 Days 返回指标"""
response = await client.get(
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=rolling_30d"
)
assert response.status_code == 200
parsed = ConsistencyMetricsResponse.model_validate(response.json())
assert parsed.influencer_id == influencer_id
assert parsed.window == ConsistencyWindow.ROLLING_30D
assert parsed.period_start < parsed.period_end
@pytest.mark.asyncio
async def test_snapshot_week_returns_metrics(self, client: AsyncClient, influencer_id: str):
"""Snapshot 周度返回指标"""
response = await client.get(
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=snapshot_week"
)
assert response.status_code == 200
parsed = ConsistencyMetricsResponse.model_validate(response.json())
assert parsed.window == ConsistencyWindow.SNAPSHOT_WEEK
assert parsed.period_start < parsed.period_end
@pytest.mark.asyncio
async def test_filter_by_rule_type(self, client: AsyncClient, influencer_id: str):
"""按规则类型筛选"""
response = await client.get(
f"/api/v1/metrics/consistency?influencer_id={influencer_id}"
"&window=rolling_30d&rule_type=forbidden_word"
)
assert response.status_code == 200
parsed = ConsistencyMetricsResponse.model_validate(response.json())
if parsed.metrics:
assert all(m.rule_type == ViolationType.FORBIDDEN_WORD for m in parsed.metrics)
@pytest.mark.asyncio
async def test_invalid_window_returns_422(self, client: AsyncClient, influencer_id: str):
"""非法窗口返回 422"""
response = await client.get(
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=invalid_window"
)
assert response.status_code == 422
@@ -0,0 +1,71 @@
"""
特例审批超时策略测试 (TDD - 红色阶段)
默认行为: 48 小时超时自动拒绝 + 必须留痕
"""
import pytest
from datetime import datetime, timedelta, timezone
from app.schemas.review import RiskExceptionRecord, RiskExceptionStatus, RiskTargetType
from app.services.risk_exception import apply_timeout_policy
class TestRiskExceptionTimeout:
"""超时自动拒绝"""
@pytest.mark.asyncio
async def test_auto_reject_after_48_hours(self):
"""超过 48 小时自动拒绝并记录原因"""
now = datetime.now(timezone.utc)
record = RiskExceptionRecord(
record_id="rec-001",
applicant_id="applicant-001",
apply_time=now - timedelta(hours=49),
target_type=RiskTargetType.INFLUENCER,
target_id="influencer-001",
risk_rule_id="rule-absolute-word",
status=RiskExceptionStatus.PENDING,
valid_start_time=now - timedelta(days=1),
valid_end_time=now + timedelta(days=3),
reason_category="业务强需",
justification="临时投放",
attachment_url=None,
current_approver_id="approver-001",
approval_chain_log=[],
auto_rejected=False,
rejection_reason=None,
last_status_at=None,
)
updated = apply_timeout_policy(record, now)
assert updated.status == RiskExceptionStatus.REJECTED
assert updated.auto_rejected is True
assert updated.rejection_reason == "timeout"
assert updated.last_status_at is not None
@pytest.mark.asyncio
async def test_no_auto_reject_within_48_hours(self):
"""未超时不应自动拒绝"""
now = datetime.now(timezone.utc)
record = RiskExceptionRecord(
record_id="rec-002",
applicant_id="applicant-002",
apply_time=now - timedelta(hours=24),
target_type=RiskTargetType.CONTENT,
target_id="content-001",
risk_rule_id="rule-soft-risk",
status=RiskExceptionStatus.PENDING,
valid_start_time=now - timedelta(days=1),
valid_end_time=now + timedelta(days=1),
reason_category="误判",
justification="内容无违规",
attachment_url=None,
current_approver_id="approver-002",
approval_chain_log=[],
auto_rejected=False,
rejection_reason=None,
last_status_at=None,
)
updated = apply_timeout_policy(record, now)
assert updated.status == RiskExceptionStatus.PENDING
assert updated.auto_rejected is False
+137
View File
@@ -0,0 +1,137 @@
"""
特例审批 API 测试 (TDD - 红色阶段)
要求: 48 小时超时自动拒绝 + 必须留痕
"""
import pytest
from datetime import datetime, timedelta, timezone
from httpx import AsyncClient
from app.schemas.review import (
RiskExceptionRecord,
RiskExceptionStatus,
)
class TestRiskExceptionCRUD:
"""特例记录基础流程"""
@pytest.mark.asyncio
async def test_create_exception_returns_201(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
"""创建特例返回 201"""
now = datetime.now(timezone.utc)
response = await client.post(
"/api/v1/risk-exceptions",
headers={"X-Tenant-ID": tenant_id},
json={
"applicant_id": applicant_id,
"target_type": "influencer",
"target_id": "influencer-001",
"risk_rule_id": "rule-absolute-word",
"reason_category": "业务强需",
"justification": "业务需要短期投放",
"attachment_url": "https://example.com/attach.png",
"current_approver_id": approver_id,
"valid_start_time": now.isoformat(),
"valid_end_time": (now + timedelta(days=7)).isoformat(),
}
)
assert response.status_code == 201
parsed = RiskExceptionRecord.model_validate(response.json())
assert parsed.status == RiskExceptionStatus.PENDING
assert parsed.current_approver_id == approver_id
@pytest.mark.asyncio
async def test_get_exception_returns_200(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
"""查询特例记录返回 200"""
headers = {"X-Tenant-ID": tenant_id}
now = datetime.now(timezone.utc)
create_resp = await client.post(
"/api/v1/risk-exceptions",
headers=headers,
json={
"applicant_id": applicant_id,
"target_type": "content",
"target_id": "content-001",
"risk_rule_id": "rule-soft-risk",
"reason_category": "误判",
"justification": "内容无违规",
"current_approver_id": approver_id,
"valid_start_time": now.isoformat(),
"valid_end_time": (now + timedelta(days=3)).isoformat(),
}
)
record_id = create_resp.json()["record_id"]
response = await client.get(
f"/api/v1/risk-exceptions/{record_id}",
headers=headers,
)
assert response.status_code == 200
parsed = RiskExceptionRecord.model_validate(response.json())
assert parsed.record_id == record_id
@pytest.mark.asyncio
async def test_approve_exception_updates_status(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
"""审批通过后状态更新为 approved"""
headers = {"X-Tenant-ID": tenant_id}
now = datetime.now(timezone.utc)
create_resp = await client.post(
"/api/v1/risk-exceptions",
headers=headers,
json={
"applicant_id": applicant_id,
"target_type": "order",
"target_id": "order-001",
"risk_rule_id": "rule-competitor",
"reason_category": "测试豁免",
"justification": "测试流程",
"current_approver_id": approver_id,
"valid_start_time": now.isoformat(),
"valid_end_time": (now + timedelta(days=1)).isoformat(),
}
)
record_id = create_resp.json()["record_id"]
response = await client.post(
f"/api/v1/risk-exceptions/{record_id}/approve",
headers=headers,
json={
"approver_id": approver_id,
"comment": "同意",
}
)
assert response.status_code == 200
parsed = RiskExceptionRecord.model_validate(response.json())
assert parsed.status == RiskExceptionStatus.APPROVED
@pytest.mark.asyncio
async def test_reject_exception_requires_reason(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
"""驳回时需要理由"""
headers = {"X-Tenant-ID": tenant_id}
now = datetime.now(timezone.utc)
create_resp = await client.post(
"/api/v1/risk-exceptions",
headers=headers,
json={
"applicant_id": applicant_id,
"target_type": "influencer",
"target_id": "influencer-002",
"risk_rule_id": "rule-absolute-word",
"reason_category": "业务强需",
"justification": "需要豁免",
"current_approver_id": approver_id,
"valid_start_time": now.isoformat(),
"valid_end_time": (now + timedelta(days=2)).isoformat(),
}
)
record_id = create_resp.json()["record_id"]
response = await client.post(
f"/api/v1/risk-exceptions/{record_id}/reject",
headers=headers,
json={
"approver_id": approver_id,
"comment": "",
}
)
assert response.status_code == 422
+385
View File
@@ -0,0 +1,385 @@
"""
规则管理 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
+331
View File
@@ -0,0 +1,331 @@
"""
脚本预审 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)
+63
View File
@@ -0,0 +1,63 @@
"""
软性风控逻辑测试 (TDD - 红色阶段)
触发条件: 临界值、低置信度、历史记录
"""
import pytest
from app.schemas.review import SoftRiskContext, SoftRiskAction
from app.services.soft_risk import evaluate_soft_risk
class TestSoftRiskEvaluator:
"""软性风控判定"""
@pytest.mark.asyncio
async def test_near_threshold_warns(self):
"""临界值接近阈值触发二次确认提示"""
context = SoftRiskContext(
violation_rate=0.045,
violation_threshold=0.05,
)
warnings = evaluate_soft_risk(context)
matched = [
w for w in warnings
if w.code == "NEAR_THRESHOLD" and w.action_required == SoftRiskAction.CONFIRM
]
assert matched
assert all(w.blocking is False for w in matched)
@pytest.mark.asyncio
async def test_low_confidence_warns(self):
"""ASR/OCR 置信度处于 60%-80% 触发备注提示"""
context = SoftRiskContext(
asr_confidence=0.7,
ocr_confidence=0.65,
)
warnings = evaluate_soft_risk(context)
codes = {w.code for w in warnings}
assert "LOW_CONFIDENCE_ASR" in codes or "LOW_CONFIDENCE_OCR" in codes
assert all(w.action_required == SoftRiskAction.NOTE for w in warnings if "LOW_CONFIDENCE" in w.code)
@pytest.mark.asyncio
async def test_history_violation_warns(self):
"""历史记录存在类似违规触发备注提示"""
context = SoftRiskContext(
has_history_violation=True,
)
warnings = evaluate_soft_risk(context)
matched = [w for w in warnings if w.code == "HISTORY_RISK"]
assert matched
assert all(w.action_required == SoftRiskAction.NOTE for w in matched)
@pytest.mark.asyncio
async def test_safe_context_returns_empty(self):
"""安全场景无软性提示"""
context = SoftRiskContext(
violation_rate=0.01,
violation_threshold=0.05,
asr_confidence=0.95,
ocr_confidence=0.92,
has_history_violation=False,
)
warnings = evaluate_soft_risk(context)
assert warnings == []
+428
View File
@@ -0,0 +1,428 @@
"""
审核任务 API 测试 (TDD - 红色阶段)
测试覆盖: 创建任务、查询任务、更新任务状态
"""
import pytest
from httpx import AsyncClient
from app.schemas.review import TaskResponse, TaskListResponse, TaskStatus
class TestCreateTask:
"""创建审核任务"""
@pytest.mark.asyncio
async def test_create_task_returns_201(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""创建任务返回 201"""
response = await client.post(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
json={
"platform": "douyin",
"creator_id": creator_id,
"video_url": video_url,
}
)
assert response.status_code == 201
@pytest.mark.asyncio
async def test_create_task_returns_task_id(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""创建任务返回任务 ID"""
response = await client.post(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
json={
"platform": "douyin",
"creator_id": creator_id,
"video_url": video_url,
}
)
data = response.json()
parsed = TaskResponse.model_validate(data)
assert parsed.task_id
@pytest.mark.asyncio
async def test_create_task_initial_status_pending(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""创建任务初始状态为 pending"""
response = await client.post(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
json={
"platform": "douyin",
"creator_id": creator_id,
"video_url": video_url,
}
)
data = response.json()
parsed = TaskResponse.model_validate(data)
assert parsed.status == TaskStatus.PENDING
@pytest.mark.asyncio
async def test_create_task_validates_platform(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""创建任务校验平台参数"""
response = await client.post(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
json={
"platform": "invalid_platform",
"creator_id": creator_id,
"video_url": video_url,
}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_task_validates_video_url(self, client: AsyncClient, tenant_id: str, creator_id: str):
"""创建任务校验视频 URL"""
response = await client.post(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
json={
"video_url": "not-a-url",
"platform": "douyin",
"creator_id": creator_id,
}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_task_allows_missing_video(self, client: AsyncClient, tenant_id: str, creator_id: str):
"""创建任务允许暂不上传视频"""
response = await client.post(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
json={
"platform": "douyin",
"creator_id": creator_id,
}
)
data = response.json()
parsed = TaskResponse.model_validate(data)
assert parsed.has_video is False
@pytest.mark.asyncio
async def test_create_task_with_script_content(self, client: AsyncClient, tenant_id: str, creator_id: str):
"""创建任务可携带脚本内容"""
response = await client.post(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
json={
"platform": "douyin",
"creator_id": creator_id,
"script_content": "脚本内容示例",
}
)
data = response.json()
parsed = TaskResponse.model_validate(data)
assert parsed.has_script is True
assert parsed.script_content == "脚本内容示例"
class TestGetTask:
"""查询审核任务"""
@pytest.mark.asyncio
async def test_get_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""查询存在的任务返回 200"""
headers = {"X-Tenant-ID": tenant_id}
# 先创建任务
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"platform": "douyin",
"creator_id": creator_id,
"video_url": video_url,
}
)
task_id = create_resp.json()["task_id"]
# 查询任务
response = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_task_returns_task_details(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""查询任务返回完整信息"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"video_url": video_url,
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
data = response.json()
parsed = TaskResponse.model_validate(data)
assert parsed.task_id == task_id
assert parsed.video_url == video_url
assert parsed.platform.value == "douyin"
assert parsed.creator_id == creator_id
assert parsed.has_video is True
assert parsed.created_at
@pytest.mark.asyncio
async def test_get_nonexistent_task_returns_404(self, client: AsyncClient, tenant_id: str):
"""查询不存在的任务返回 404"""
response = await client.get(
"/api/v1/tasks/nonexistent-task-id",
headers={"X-Tenant-ID": tenant_id},
)
assert response.status_code == 404
class TestListTasks:
"""任务列表查询"""
@pytest.mark.asyncio
async def test_list_tasks_returns_200(self, client: AsyncClient, tenant_id: str):
"""查询任务列表返回 200"""
response = await client.get(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_list_tasks_returns_array(self, client: AsyncClient, tenant_id: str):
"""查询任务列表返回数组"""
response = await client.get(
"/api/v1/tasks",
headers={"X-Tenant-ID": tenant_id},
)
data = response.json()
parsed = TaskListResponse.model_validate(data)
assert isinstance(parsed.items, list)
@pytest.mark.asyncio
async def test_list_tasks_pagination(self, client: AsyncClient, tenant_id: str):
"""任务列表支持分页"""
response = await client.get(
"/api/v1/tasks?page=1&page_size=10",
headers={"X-Tenant-ID": tenant_id},
)
data = response.json()
parsed = TaskListResponse.model_validate(data)
assert parsed.page == 1
assert parsed.page_size == 10
@pytest.mark.asyncio
async def test_list_tasks_filter_by_status(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""任务列表支持按状态筛选"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"video_url": video_url,
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.get("/api/v1/tasks?status=pending", headers=headers)
assert response.status_code == 200
data = response.json()
parsed = TaskListResponse.model_validate(data)
assert any(item.task_id == task_id for item in parsed.items)
@pytest.mark.asyncio
async def test_list_tasks_filter_by_platform(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""任务列表支持按平台筛选"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"video_url": video_url,
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.get("/api/v1/tasks?platform=douyin", headers=headers)
assert response.status_code == 200
data = response.json()
parsed = TaskListResponse.model_validate(data)
assert any(item.task_id == task_id for item in parsed.items)
class TestUploadTaskAssets:
"""任务脚本/视频上传"""
@pytest.mark.asyncio
async def test_upload_script_requires_payload(self, client: AsyncClient, tenant_id: str, creator_id: str):
"""上传脚本必须提供内容或文件 URL"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.post(
f"/api/v1/tasks/{task_id}/script",
headers=headers,
json={},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_upload_script_updates_task(self, client: AsyncClient, tenant_id: str, creator_id: str):
"""上传脚本更新任务内容"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.post(
f"/api/v1/tasks/{task_id}/script",
headers=headers,
json={"script_content": "更新后的脚本"},
)
data = response.json()
parsed = TaskResponse.model_validate(data)
assert parsed.has_script is True
assert parsed.script_content == "更新后的脚本"
@pytest.mark.asyncio
async def test_upload_video_updates_task(self, client: AsyncClient, tenant_id: str, creator_id: str, video_url: str):
"""上传视频更新任务视频 URL"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.post(
f"/api/v1/tasks/{task_id}/video",
headers=headers,
json={"video_url": video_url},
)
data = response.json()
parsed = TaskResponse.model_validate(data)
assert parsed.has_video is True
assert parsed.video_url == video_url
class TestUpdateTaskStatus:
"""更新任务状态"""
@pytest.mark.asyncio
async def test_approve_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""通过任务返回 200"""
headers = {"X-Tenant-ID": tenant_id}
# 创建任务
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"video_url": video_url,
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
# 通过任务
response = await client.post(
f"/api/v1/tasks/{task_id}/approve",
headers=headers,
json={"comment": "审核通过"}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_approve_task_updates_status(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""通过任务更新状态为 approved"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"video_url": video_url,
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
await client.post(
f"/api/v1/tasks/{task_id}/approve",
headers=headers,
json={"comment": "审核通过"}
)
# 验证状态
get_resp = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
parsed = TaskResponse.model_validate(get_resp.json())
assert parsed.status == TaskStatus.APPROVED
@pytest.mark.asyncio
async def test_reject_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""驳回任务返回 200"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"video_url": video_url,
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.post(
f"/api/v1/tasks/{task_id}/reject",
headers=headers,
json={"reason": "违规内容", "violations": ["forbidden_word"]}
)
assert response.status_code == 200
get_resp = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
parsed = TaskResponse.model_validate(get_resp.json())
assert parsed.status == TaskStatus.REJECTED
@pytest.mark.asyncio
async def test_reject_task_requires_reason(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
"""驳回任务必须提供原因"""
headers = {"X-Tenant-ID": tenant_id}
create_resp = await client.post(
"/api/v1/tasks",
headers=headers,
json={
"video_url": video_url,
"platform": "douyin",
"creator_id": creator_id,
}
)
task_id = create_resp.json()["task_id"]
response = await client.post(
f"/api/v1/tasks/{task_id}/reject",
headers=headers,
json={}
)
assert response.status_code == 422
+422
View File
@@ -0,0 +1,422 @@
"""
视频审核 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
+464
View File
@@ -0,0 +1,464 @@
"""
视频审核服务层测试 (TDD - 红色阶段)
测试覆盖: 违规检测核心逻辑、时长频次校验、风险等级分类
这些测试验证实际检测结果,而非仅 HTTP 状态码
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
class TestCompetitorLogoDetection:
"""竞品 Logo 检测逻辑"""
@pytest.mark.asyncio
async def test_detect_competitor_logo_in_frame(self):
"""检测画面中的竞品 Logo"""
# 导入服务(实现后才能通过)
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# 模拟视频帧数据(包含竞品 Logo)
mock_frames = [
{"timestamp": 10.0, "objects": [{"label": "competitor-brand-A", "confidence": 0.95}]},
{"timestamp": 45.0, "objects": [{"label": "competitor-brand-A", "confidence": 0.88}]},
]
violations = await service.detect_competitor_logos(
frames=mock_frames,
competitors=["competitor-brand-A", "competitor-brand-B"]
)
# 应该检测到 2 处竞品露出
assert len(violations) == 2
assert violations[0]["type"] == "competitor_logo"
assert violations[0]["timestamp"] == 10.0
assert violations[0]["risk_level"] == "medium"
@pytest.mark.asyncio
async def test_no_detection_when_no_competitor(self):
"""无竞品时不应检测到违规"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
mock_frames = [
{"timestamp": 10.0, "objects": [{"label": "product-A", "confidence": 0.95}]},
]
violations = await service.detect_competitor_logos(
frames=mock_frames,
competitors=["competitor-brand-X"] # 不在画面中
)
assert len(violations) == 0
@pytest.mark.asyncio
async def test_ignore_low_confidence_detection(self):
"""忽略低置信度检测"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
mock_frames = [
{"timestamp": 10.0, "objects": [{"label": "competitor-brand-A", "confidence": 0.3}]}, # 低置信度
]
violations = await service.detect_competitor_logos(
frames=mock_frames,
competitors=["competitor-brand-A"],
min_confidence=0.7
)
assert len(violations) == 0
class TestForbiddenWordDetectionInSpeech:
"""口播违禁词检测(ASR"""
@pytest.mark.asyncio
async def test_detect_forbidden_word_in_transcript(self):
"""检测语音转文字中的违禁词"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# 模拟 ASR 转写结果
mock_transcript = [
{"text": "这是一款很好的产品", "start": 0.0, "end": 3.0},
{"text": "我们的产品是最好的", "start": 5.0, "end": 8.0}, # 包含"最好"
{"text": "销量第一名", "start": 10.0, "end": 12.0}, # 包含"第一"
]
violations = await service.detect_forbidden_words_in_speech(
transcript=mock_transcript,
forbidden_words=["最好", "第一", "最佳"]
)
# 应该检测到 2 处违规
assert len(violations) == 2
# 验证第一个违规
assert violations[0]["type"] == "forbidden_word"
assert violations[0]["content"] == "最好"
assert violations[0]["timestamp"] == 5.0
assert violations[0]["source"] == "speech"
assert "suggestion" in violations[0]
# 验证第二个违规
assert violations[1]["content"] == "第一"
assert violations[1]["timestamp"] == 10.0
@pytest.mark.asyncio
async def test_context_aware_detection(self):
"""语境感知检测 - 非广告语境不标记"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# 非广告语境
mock_transcript = [
{"text": "今天是我最开心的一天", "start": 0.0, "end": 3.0}, # 非广告语境
]
violations = await service.detect_forbidden_words_in_speech(
transcript=mock_transcript,
forbidden_words=[""],
context_aware=True # 启用语境感知
)
# 非广告语境不应标记
assert len(violations) == 0
@pytest.mark.asyncio
async def test_ad_context_flagged(self):
"""广告语境应标记"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# 广告语境
mock_transcript = [
{"text": "我们的产品是最好的选择", "start": 0.0, "end": 3.0},
]
violations = await service.detect_forbidden_words_in_speech(
transcript=mock_transcript,
forbidden_words=["最好"],
context_aware=True
)
# 广告语境应标记
assert len(violations) == 1
class TestForbiddenWordDetectionInSubtitle:
"""字幕违禁词检测(OCR"""
@pytest.mark.asyncio
async def test_detect_forbidden_word_in_subtitle(self):
"""检测字幕中的违禁词"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# 模拟 OCR 结果
mock_subtitles = [
{"text": "限时特惠", "timestamp": 5.0},
{"text": "效果最佳", "timestamp": 15.0}, # 包含"最佳"
{"text": "立即购买", "timestamp": 25.0},
]
violations = await service.detect_forbidden_words_in_subtitle(
subtitles=mock_subtitles,
forbidden_words=["最佳", "第一", "最好"]
)
assert len(violations) == 1
assert violations[0]["content"] == "最佳"
assert violations[0]["timestamp"] == 15.0
assert violations[0]["source"] == "subtitle"
class TestDurationCheck:
"""时长校验"""
@pytest.mark.asyncio
async def test_product_display_duration_sufficient(self):
"""产品同框时长充足时通过"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# 模拟产品出现时间段
mock_product_appearances = [
{"start": 5.0, "end": 15.0}, # 10 秒
{"start": 30.0, "end": 35.0}, # 5 秒
]
violations = await service.check_product_display_duration(
appearances=mock_product_appearances,
min_seconds=10
)
# 总时长 15 秒 >= 要求 10 秒,应该通过
assert len(violations) == 0
@pytest.mark.asyncio
async def test_product_display_duration_insufficient(self):
"""产品同框时长不足时报违规"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
mock_product_appearances = [
{"start": 5.0, "end": 8.0}, # 3 秒
]
violations = await service.check_product_display_duration(
appearances=mock_product_appearances,
min_seconds=10
)
# 总时长 3 秒 < 要求 10 秒,应该报违规
assert len(violations) == 1
assert violations[0]["type"] == "duration_short"
assert "3" in violations[0]["content"] or "" in violations[0]["content"]
assert violations[0]["risk_level"] == "medium"
class TestBrandMentionFrequency:
"""品牌提及频次校验"""
@pytest.mark.asyncio
async def test_brand_mention_sufficient(self):
"""品牌提及次数充足时通过"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
mock_transcript = [
{"text": "今天介绍品牌A的产品", "start": 0.0, "end": 3.0},
{"text": "品牌A真的很好用", "start": 10.0, "end": 13.0},
{"text": "推荐大家试试品牌A", "start": 20.0, "end": 23.0},
]
violations = await service.check_brand_mention_frequency(
transcript=mock_transcript,
brand_name="品牌A",
min_mentions=3
)
# 提及 3 次 >= 要求 3 次,应该通过
assert len(violations) == 0
@pytest.mark.asyncio
async def test_brand_mention_insufficient(self):
"""品牌提及次数不足时报违规"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
mock_transcript = [
{"text": "今天介绍品牌A的产品", "start": 0.0, "end": 3.0},
]
violations = await service.check_brand_mention_frequency(
transcript=mock_transcript,
brand_name="品牌A",
min_mentions=3
)
# 提及 1 次 < 要求 3 次,应该报违规
assert len(violations) == 1
assert violations[0]["type"] == "mention_missing"
class TestRiskLevelClassification:
"""风险等级分类"""
@pytest.mark.asyncio
async def test_legal_violation_is_high_risk(self):
"""法律违规(广告法)标记为高风险"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
violation = {
"type": "forbidden_word",
"content": "最好",
"category": "absolute_term", # 广告法极限词
}
risk_level = service.classify_risk_level(violation)
assert risk_level == "high"
@pytest.mark.asyncio
async def test_platform_violation_is_medium_risk(self):
"""平台规则违规标记为中风险"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
violation = {
"type": "duration_short",
"category": "platform_rule",
}
risk_level = service.classify_risk_level(violation)
assert risk_level == "medium"
@pytest.mark.asyncio
async def test_brand_guideline_is_low_risk(self):
"""品牌规范违规标记为低风险"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
violation = {
"type": "mention_missing",
"category": "brand_guideline",
}
risk_level = service.classify_risk_level(violation)
assert risk_level == "low"
class TestScoreCalculation:
"""合规分数计算"""
@pytest.mark.asyncio
async def test_perfect_score_no_violations(self):
"""无违规时满分"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
score = service.calculate_score(violations=[])
assert score == 100
@pytest.mark.asyncio
async def test_high_risk_violation_major_deduction(self):
"""高风险违规大幅扣分"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
violations = [
{"type": "forbidden_word", "risk_level": "high"},
]
score = service.calculate_score(violations=violations)
# 高风险违规应该扣 20-30 分
assert score <= 80
@pytest.mark.asyncio
async def test_multiple_violations_cumulative_deduction(self):
"""多个违规累计扣分"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
violations = [
{"type": "forbidden_word", "risk_level": "high"},
{"type": "forbidden_word", "risk_level": "high"},
{"type": "duration_short", "risk_level": "medium"},
]
score = service.calculate_score(violations=violations)
# 多个违规累计,分数应该更低
assert score <= 60
@pytest.mark.asyncio
async def test_score_never_below_zero(self):
"""分数不会低于 0"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# 大量违规
violations = [{"type": "forbidden_word", "risk_level": "high"} for _ in range(20)]
score = service.calculate_score(violations=violations)
assert score >= 0
class TestFullReviewPipeline:
"""完整审核流程测试"""
@pytest.mark.asyncio
async def test_review_video_with_violations(self):
"""审核包含违规的视频"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# Mock AI 服务
service.asr_service = AsyncMock()
service.asr_service.transcribe.return_value = [
{"text": "这是最好的产品", "start": 5.0, "end": 8.0},
]
service.cv_service = AsyncMock()
service.cv_service.detect_objects.return_value = [
{"timestamp": 10.0, "objects": [{"label": "competitor-A", "confidence": 0.9}]},
]
service.ocr_service = AsyncMock()
service.ocr_service.extract_subtitles.return_value = []
result = await service.review_video(
video_url="https://example.com/video.mp4",
platform="douyin",
brand_id="brand-001",
competitors=["competitor-A"],
forbidden_words=["最好"],
)
# 验证结果结构
assert "score" in result
assert "summary" in result
assert "violations" in result
# 应该检测到违规
assert len(result["violations"]) >= 2 # 至少:口播违禁词 + 竞品 Logo
assert result["score"] < 100
# 验证违规项结构
for violation in result["violations"]:
assert "type" in violation
assert "content" in violation
assert "timestamp" in violation
assert "risk_level" in violation
assert "suggestion" in violation
@pytest.mark.asyncio
async def test_review_clean_video(self):
"""审核无违规的视频"""
from app.services.video_review import VideoReviewService
service = VideoReviewService()
# Mock AI 服务 - 无违规内容
service.asr_service = AsyncMock()
service.asr_service.transcribe.return_value = [
{"text": "今天给大家分享护肤技巧", "start": 0.0, "end": 3.0},
]
service.cv_service = AsyncMock()
service.cv_service.detect_objects.return_value = []
service.ocr_service = AsyncMock()
service.ocr_service.extract_subtitles.return_value = []
result = await service.review_video(
video_url="https://example.com/clean_video.mp4",
platform="douyin",
brand_id="brand-001",
competitors=[],
forbidden_words=["最好"],
)
# 无违规,满分
assert len(result["violations"]) == 0
assert result["score"] == 100