video-compliance-ai/backend/tests/test_briefs_api.py
Your Name 864af19011 test: 补全后端 API 测试覆盖(Organizations/Projects/Dashboard/Briefs/Export)
新增 157 个测试,总计 368 个测试全部通过:
- Organizations API: 50 tests (品牌方↔代理商↔达人关系管理 + 搜索 + 权限)
- Projects API: 44 tests (CRUD + 分页 + 状态筛选 + 代理商分配 + 权限)
- Dashboard API: 17 tests (三端工作台统计 + 角色隔离 + 认证)
- Briefs API: 24 tests (CRUD + 权限 + 数据完整性)
- Export API: 22 tests (CSV 导出 + UTF-8 BOM + 角色权限 + 格式验证)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:18:49 +08:00

566 lines
20 KiB
Python

"""
Briefs API comprehensive tests.
Tests cover the Brief CRUD endpoints under a project:
- GET /api/v1/projects/{project_id}/brief (brand, agency, creator can read)
- POST /api/v1/projects/{project_id}/brief (brand only, 201)
- PUT /api/v1/projects/{project_id}/brief (brand only)
Permissions:
- Brand: full CRUD (create, read, update)
- Agency: read only (403 on create/update), but only if assigned to project
- Creator: read only (403 on create/update)
- Unauthenticated: 401
Uses the SQLite-backed test client from conftest.py.
"""
import uuid
import pytest
from httpx import AsyncClient
from app.main import app
from app.middleware.rate_limit import RateLimitMiddleware
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
API = "/api/v1"
REGISTER_URL = f"{API}/auth/register"
PROJECTS_URL = f"{API}/projects"
def _brief_url(project_id: str) -> str:
"""Return the Brief endpoint URL for a given project."""
return f"{API}/projects/{project_id}/brief"
# ---------------------------------------------------------------------------
# Auto-clear rate limiter state before each test
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clear_rate_limiter():
"""Reset the in-memory rate limiter between tests."""
mw = app.middleware_stack
while mw is not None:
if isinstance(mw, RateLimitMiddleware):
mw.requests.clear()
break
mw = getattr(mw, "app", None)
yield
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _email(prefix: str = "user") -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}@test.com"
async def _register(client: AsyncClient, role: str, name: str | None = None):
"""Register a user via the API and return (access_token, user_data)."""
email = _email(role)
resp = await client.post(REGISTER_URL, json={
"email": email,
"password": "test123456",
"name": name or f"Test {role.title()}",
"role": role,
})
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
data = resp.json()
return data["access_token"], data["user"]
def _auth(token: str) -> dict:
"""Return Authorization header dict."""
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# Sample brief payloads
# ---------------------------------------------------------------------------
SAMPLE_BRIEF = {
"selling_points": [
{"text": "SPF50+ 防晒", "priority": 1},
{"text": "轻薄不油腻", "priority": 2},
],
"blacklist_words": [
{"word": "最好", "reason": "绝对化用语"},
{"word": "第一", "reason": "绝对化用语"},
],
"competitors": ["竞品A", "竞品B"],
"brand_tone": "活泼年轻",
"min_duration": 15,
"max_duration": 60,
"other_requirements": "请在视频开头3秒内展示产品",
"attachments": [],
}
# ---------------------------------------------------------------------------
# Fixture: Brand + Project setup
# ---------------------------------------------------------------------------
@pytest.fixture
async def brand_with_project(client: AsyncClient):
"""
Register a brand user and create a project.
Returns a dict with keys:
brand_token, brand_user, brand_id, project_id
"""
brand_token, brand_user = await _register(client, "brand", "BriefTestBrand")
brand_id = brand_user["brand_id"]
# Brand creates a project
resp = await client.post(PROJECTS_URL, json={
"name": "Brief Test Project",
"description": "Project for brief testing",
}, headers=_auth(brand_token))
assert resp.status_code == 201, f"Project creation failed: {resp.text}"
project_id = resp.json()["id"]
return {
"brand_token": brand_token,
"brand_user": brand_user,
"brand_id": brand_id,
"project_id": project_id,
}
# ===========================================================================
# Test class: Brief Creation
# ===========================================================================
class TestBriefCreation:
"""POST /api/v1/projects/{project_id}/brief"""
@pytest.mark.asyncio
async def test_create_brief_happy_path(
self, client: AsyncClient, brand_with_project
):
"""Brand can create a brief -- returns 201 with correct data."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
resp = await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
assert resp.status_code == 201
data = resp.json()
assert data["id"].startswith("BF")
assert data["project_id"] == setup["project_id"]
assert data["brand_tone"] == "活泼年轻"
assert data["min_duration"] == 15
assert data["max_duration"] == 60
assert data["other_requirements"] == "请在视频开头3秒内展示产品"
assert len(data["selling_points"]) == 2
assert len(data["blacklist_words"]) == 2
assert data["competitors"] == ["竞品A", "竞品B"]
assert data["attachments"] == []
assert "created_at" in data
assert "updated_at" in data
@pytest.mark.asyncio
async def test_create_brief_minimal_payload(
self, client: AsyncClient, brand_with_project
):
"""Brand can create a brief with minimal fields (all optional)."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
resp = await client.post(url, json={}, headers=_auth(setup["brand_token"]))
assert resp.status_code == 201
data = resp.json()
assert data["id"].startswith("BF")
assert data["project_id"] == setup["project_id"]
assert data["selling_points"] is None
assert data["brand_tone"] is None
@pytest.mark.asyncio
async def test_create_brief_duplicate_returns_400(
self, client: AsyncClient, brand_with_project
):
"""Creating a second brief on the same project returns 400."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# First creation
resp1 = await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
assert resp1.status_code == 201
# Second creation -- should fail
resp2 = await client.post(url, json={"brand_tone": "不同调性"}, headers=_auth(setup["brand_token"]))
assert resp2.status_code == 400
assert "已有" in resp2.json()["detail"]
@pytest.mark.asyncio
async def test_create_brief_agency_forbidden(
self, client: AsyncClient, brand_with_project
):
"""Agency cannot create a brief -- expects 403."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
agency_token, _ = await _register(client, "agency", "AgencyNoBrief")
resp = await client.post(url, json=SAMPLE_BRIEF, headers=_auth(agency_token))
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_brief_creator_forbidden(
self, client: AsyncClient, brand_with_project
):
"""Creator cannot create a brief -- expects 403."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
creator_token, _ = await _register(client, "creator", "CreatorNoBrief")
resp = await client.post(url, json=SAMPLE_BRIEF, headers=_auth(creator_token))
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_brief_nonexistent_project(self, client: AsyncClient):
"""Creating a brief on a nonexistent project returns 404."""
brand_token, _ = await _register(client, "brand", "BrandNoProject")
url = _brief_url("PJ000000")
resp = await client.post(url, json=SAMPLE_BRIEF, headers=_auth(brand_token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_create_brief_wrong_brand_project(self, client: AsyncClient):
"""Brand cannot create a brief on another brand's project -- expects 403."""
# Brand A creates a project
brand_a_token, _ = await _register(client, "brand", "BrandA")
resp = await client.post(PROJECTS_URL, json={
"name": "BrandA Project",
}, headers=_auth(brand_a_token))
assert resp.status_code == 201
project_id = resp.json()["id"]
# Brand B tries to create a brief on Brand A's project
brand_b_token, _ = await _register(client, "brand", "BrandB")
url = _brief_url(project_id)
resp = await client.post(url, json=SAMPLE_BRIEF, headers=_auth(brand_b_token))
assert resp.status_code == 403
# ===========================================================================
# Test class: Brief Read
# ===========================================================================
class TestBriefRead:
"""GET /api/v1/projects/{project_id}/brief"""
@pytest.mark.asyncio
async def test_get_brief_by_brand(
self, client: AsyncClient, brand_with_project
):
"""Brand can read the brief they created."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create the brief first
create_resp = await client.post(
url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"])
)
assert create_resp.status_code == 201
# Read it back
resp = await client.get(url, headers=_auth(setup["brand_token"]))
assert resp.status_code == 200
data = resp.json()
assert data["project_id"] == setup["project_id"]
assert data["brand_tone"] == "活泼年轻"
assert data["min_duration"] == 15
assert data["max_duration"] == 60
assert len(data["selling_points"]) == 2
@pytest.mark.asyncio
async def test_get_brief_404_before_creation(
self, client: AsyncClient, brand_with_project
):
"""Getting a brief that doesn't exist yet returns 404."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
resp = await client.get(url, headers=_auth(setup["brand_token"]))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_brief_creator_can_read(
self, client: AsyncClient, brand_with_project
):
"""Creator can read a brief (read-only access)."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create the brief
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
# Creator reads
creator_token, _ = await _register(client, "creator", "CreatorReader")
resp = await client.get(url, headers=_auth(creator_token))
assert resp.status_code == 200
assert resp.json()["brand_tone"] == "活泼年轻"
@pytest.mark.asyncio
async def test_get_brief_nonexistent_project(self, client: AsyncClient):
"""Getting a brief on a nonexistent project returns 404."""
brand_token, _ = await _register(client, "brand")
url = _brief_url("PJ000000")
resp = await client.get(url, headers=_auth(brand_token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_brief_wrong_brand(
self, client: AsyncClient, brand_with_project
):
"""Another brand cannot read this brand's project brief -- expects 403."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create the brief
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
# Another brand tries to read
other_brand_token, _ = await _register(client, "brand", "OtherBrand")
resp = await client.get(url, headers=_auth(other_brand_token))
assert resp.status_code == 403
# ===========================================================================
# Test class: Brief Update
# ===========================================================================
class TestBriefUpdate:
"""PUT /api/v1/projects/{project_id}/brief"""
@pytest.mark.asyncio
async def test_update_brief_brand_tone(
self, client: AsyncClient, brand_with_project
):
"""Brand can update the brand_tone field."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
# Update
resp = await client.put(
url,
json={"brand_tone": "高端大气"},
headers=_auth(setup["brand_token"]),
)
assert resp.status_code == 200
assert resp.json()["brand_tone"] == "高端大气"
@pytest.mark.asyncio
async def test_update_brief_selling_points(
self, client: AsyncClient, brand_with_project
):
"""Brand can update selling_points list."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
new_selling_points = [
{"text": "新卖点A", "priority": 1},
]
resp = await client.put(
url,
json={"selling_points": new_selling_points},
headers=_auth(setup["brand_token"]),
)
assert resp.status_code == 200
data = resp.json()
assert len(data["selling_points"]) == 1
assert data["selling_points"][0]["text"] == "新卖点A"
@pytest.mark.asyncio
async def test_update_brief_duration_range(
self, client: AsyncClient, brand_with_project
):
"""Brand can update min/max duration."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
resp = await client.put(
url,
json={"min_duration": 30, "max_duration": 120},
headers=_auth(setup["brand_token"]),
)
assert resp.status_code == 200
data = resp.json()
assert data["min_duration"] == 30
assert data["max_duration"] == 120
@pytest.mark.asyncio
async def test_update_brief_preserves_unchanged_fields(
self, client: AsyncClient, brand_with_project
):
"""Updating one field does not affect other fields."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create with full payload
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
# Update only brand_tone
resp = await client.put(
url,
json={"brand_tone": "新调性"},
headers=_auth(setup["brand_token"]),
)
assert resp.status_code == 200
data = resp.json()
assert data["brand_tone"] == "新调性"
# Other fields should remain unchanged
assert data["min_duration"] == 15
assert data["max_duration"] == 60
assert data["competitors"] == ["竞品A", "竞品B"]
@pytest.mark.asyncio
async def test_update_brief_404_before_creation(
self, client: AsyncClient, brand_with_project
):
"""Updating a brief that doesn't exist returns 404."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
resp = await client.put(
url,
json={"brand_tone": "不存在的"},
headers=_auth(setup["brand_token"]),
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_brief_agency_forbidden(
self, client: AsyncClient, brand_with_project
):
"""Agency cannot update a brief -- expects 403."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create the brief
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
# Agency tries to update
agency_token, _ = await _register(client, "agency", "AgencyNoUpdate")
resp = await client.put(
url,
json={"brand_tone": "Agency tone"},
headers=_auth(agency_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_update_brief_creator_forbidden(
self, client: AsyncClient, brand_with_project
):
"""Creator cannot update a brief -- expects 403."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create the brief
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
# Creator tries to update
creator_token, _ = await _register(client, "creator", "CreatorNoUpdate")
resp = await client.put(
url,
json={"brand_tone": "Creator tone"},
headers=_auth(creator_token),
)
assert resp.status_code == 403
# ===========================================================================
# Test class: Brief Permissions
# ===========================================================================
class TestBriefPermissions:
"""Authentication and cross-project permission tests."""
@pytest.mark.asyncio
async def test_get_brief_unauthenticated(
self, client: AsyncClient, brand_with_project
):
"""Unauthenticated GET brief returns 401."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
resp = await client.get(url)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_create_brief_unauthenticated(
self, client: AsyncClient, brand_with_project
):
"""Unauthenticated POST brief returns 401."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
resp = await client.post(url, json=SAMPLE_BRIEF)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_update_brief_unauthenticated(
self, client: AsyncClient, brand_with_project
):
"""Unauthenticated PUT brief returns 401."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
resp = await client.put(url, json={"brand_tone": "test"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_brief_with_invalid_token(
self, client: AsyncClient, brand_with_project
):
"""Request with an invalid Bearer token returns 401."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
headers = {"Authorization": "Bearer invalid-garbage-token"}
for method_func, kwargs in [
(client.get, {}),
(client.post, {"json": SAMPLE_BRIEF}),
(client.put, {"json": {"brand_tone": "x"}}),
]:
resp = await method_func(url, headers=headers, **kwargs)
assert resp.status_code == 401, (
f"Expected 401, got {resp.status_code} for {method_func.__name__}"
)
@pytest.mark.asyncio
async def test_update_brief_wrong_brand(
self, client: AsyncClient, brand_with_project
):
"""Another brand cannot update this brand's project brief -- expects 403."""
setup = brand_with_project
url = _brief_url(setup["project_id"])
# Create the brief
await client.post(url, json=SAMPLE_BRIEF, headers=_auth(setup["brand_token"]))
# Another brand tries to update
other_brand_token, _ = await _register(client, "brand", "WrongBrand")
resp = await client.put(
url,
json={"brand_tone": "Hacker tone"},
headers=_auth(other_brand_token),
)
assert resp.status_code == 403