feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -0,0 +1 @@
|
||||
"""测试模块"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user