新增 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>
566 lines
20 KiB
Python
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
|