主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
465 lines
11 KiB
Python
465 lines
11 KiB
Python
"""
|
||
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
|