chore: CI/CD + 前端测试 + 安全加固 + 限流完善

- 新增 .gitlab-ci.yml (lint/test/build 三阶段)
- 新增前端测试: taskStageMapper (109), api.ts (36), AuthContext (16)
- 修复旧测试: Sidebar 导航文案、MobileLayout padding 值
- python-jose → PyJWT 消除 ecdsa CVE 漏洞
- 限流中间件增加 5 个敏感端点精细限流 + 标准限流头

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-10 11:25:29 +08:00
parent 3a444864ac
commit a2f6f82e15
9 changed files with 2100 additions and 22 deletions

94
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,94 @@
stages:
- lint
- test
- build
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.cache/npm"
# ─── Backend Jobs ────────────────────────────────────────────────
backend-lint:
stage: lint
image: python:3.12-slim
only:
- main
- merge_requests
cache:
key: backend-pip
paths:
- .cache/pip
script:
- cd backend
- python3 -c "
import ast, sys, pathlib;
ok = True;
for p in sorted(pathlib.Path('.').rglob('*.py')):
try:
ast.parse(p.read_text());
except SyntaxError as e:
print(f'SyntaxError in {p}\u003a {e}');
ok = False;
if not ok:
sys.exit(1);
print(f'All {len(list(pathlib.Path(\".\").rglob(\"*.py\")))} Python files passed syntax check')
"
backend-test:
stage: test
image: python:3.12-slim
only:
- main
- merge_requests
cache:
key: backend-pip
paths:
- .cache/pip
variables:
DATABASE_URL: "sqlite+aiosqlite:///./test.db"
SECRET_KEY: "ci-test-secret-key"
script:
- cd backend
- pip install -e ".[dev]" aiosqlite "bcrypt<5" --quiet
- pytest tests/ -x -q --tb=short
# ─── Frontend Jobs ───────────────────────────────────────────────
.frontend-base:
image: node:20-slim
only:
- main
- merge_requests
cache:
key: frontend-npm
paths:
- .cache/npm
- frontend/node_modules
before_script:
- cd frontend
- npm ci --prefer-offline
frontend-lint:
extends: .frontend-base
stage: lint
script:
- npm run lint
frontend-typecheck:
extends: .frontend-base
stage: lint
script:
- npx tsc --noEmit
frontend-test:
extends: .frontend-base
stage: test
script:
- npm test -- --run
frontend-build:
extends: .frontend-base
stage: build
script:
- npm run build

View File

@ -1,6 +1,6 @@
""" """
简单的速率限制中间件 速率限制中间件
基于内存的滑动窗口计数器 基于内存的滑动窗口计数器支持按路径自定义限制和标准响应头
""" """
import time import time
from collections import defaultdict from collections import defaultdict
@ -14,50 +14,111 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
速率限制中间件 速率限制中间件
- 默认: 60 /分钟 per IP - 默认: 60 /分钟 per IP
- 登录/注册: 10 /分钟 per IP - 按路径配置不同限制 (path_limits)
- 返回标准 X-RateLimit-* 响应头
""" """
def __init__(self, app, default_limit: int = 60, window_seconds: int = 60): # Path-specific rate limits (requests per window).
# Paths not listed here fall back to ``default_limit``.
DEFAULT_PATH_LIMITS: dict[str, int] = {
# Auth endpoints — prevent brute-force / abuse
"/api/v1/auth/login": 10,
"/api/v1/auth/register": 10,
"/api/v1/auth/send-code": 5,
"/api/v1/auth/reset-password": 5,
# Upload — bandwidth / storage cost
"/api/v1/upload/policy": 30,
# AI review — service cost + compute
"/api/v1/scripts/review": 10,
"/api/v1/videos/review": 5,
}
def __init__(
self,
app,
default_limit: int = 60,
window_seconds: int = 60,
path_limits: dict[str, int] | None = None,
):
super().__init__(app) super().__init__(app)
self.default_limit = default_limit self.default_limit = default_limit
self.window_seconds = window_seconds self.window_seconds = window_seconds
self.requests: dict[str, list[float]] = defaultdict(list) self.requests: dict[str, list[float]] = defaultdict(list)
# Stricter limits for auth endpoints # Merge caller-supplied overrides on top of the built-in defaults.
self.strict_paths = {"/api/v1/auth/login", "/api/v1/auth/register"} self.path_limits: dict[str, int] = {**self.DEFAULT_PATH_LIMITS}
self.strict_limit = 10 if path_limits:
self.path_limits.update(path_limits)
def _get_limit(self, path: str) -> int:
"""Return the rate limit for *path*, falling back to *default_limit*."""
return self.path_limits.get(path, self.default_limit)
def _make_key(self, client_ip: str, path: str) -> str:
"""Build the bucket key.
Paths with a custom limit are bucketed per-IP per-path so that
hitting one endpoint does not consume the quota of another.
Default paths share a single per-IP bucket.
"""
if path in self.path_limits:
return f"{client_ip}:{path}"
return client_ip
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
path = request.url.path path = request.url.path
now = time.time() now = time.time()
# Determine rate limit limit = self._get_limit(path)
if path in self.strict_paths: key = self._make_key(client_ip, path)
key = f"{client_ip}:{path}"
limit = self.strict_limit
else:
key = client_ip
limit = self.default_limit
# Clean old entries # Clean old entries outside the sliding window
window_start = now - self.window_seconds window_start = now - self.window_seconds
self.requests[key] = [t for t in self.requests[key] if t > window_start] self.requests[key] = [t for t in self.requests[key] if t > window_start]
current_count = len(self.requests[key])
remaining = max(0, limit - current_count)
# Seconds until the oldest request in the window expires
if self.requests[key]:
reset_seconds = int(self.requests[key][0] - window_start)
else:
reset_seconds = self.window_seconds
# Build common rate-limit headers
rate_headers = {
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": str(max(0, remaining - 1) if remaining > 0 else 0),
"X-RateLimit-Reset": str(reset_seconds),
}
# Check limit # Check limit
if len(self.requests[key]) >= limit: if current_count >= limit:
return JSONResponse( return JSONResponse(
status_code=429, status_code=429,
content={"detail": "请求过于频繁,请稍后再试"}, content={"detail": "请求过于频繁,请稍后再试"},
headers={
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_seconds),
"Retry-After": str(reset_seconds),
},
) )
# Record request # Record request
self.requests[key].append(now) self.requests[key].append(now)
# Periodic cleanup (every 1000 requests to this key) # Periodic cleanup (keep memory bounded)
if len(self.requests) > 10000: if len(self.requests) > 10000:
self._cleanup(now) self._cleanup(now)
response = await call_next(request) response = await call_next(request)
# Attach rate-limit headers to successful responses
response.headers["X-RateLimit-Limit"] = rate_headers["X-RateLimit-Limit"]
response.headers["X-RateLimit-Remaining"] = rate_headers["X-RateLimit-Remaining"]
response.headers["X-RateLimit-Reset"] = rate_headers["X-RateLimit-Reset"]
return response return response
def _cleanup(self, now: float): def _cleanup(self, now: float):

View File

@ -4,7 +4,8 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import secrets import secrets
from jose import jwt, JWTError import jwt
from jwt.exceptions import PyJWTError as JWTError
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select

View File

@ -14,7 +14,7 @@ dependencies = [
"httpx>=0.26.0", "httpx>=0.26.0",
"pydantic[email]>=2.5.0", "pydantic[email]>=2.5.0",
"pydantic-settings>=2.0.0", "pydantic-settings>=2.0.0",
"python-jose>=3.3.0", "PyJWT>=2.8.0",
"passlib>=1.7.4", "passlib>=1.7.4",
"alembic>=1.13.0", "alembic>=1.13.0",
"cryptography>=42.0.0", "cryptography>=42.0.0",
@ -57,6 +57,7 @@ markers = [
] ]
filterwarnings = [ filterwarnings = [
"ignore::DeprecationWarning", "ignore::DeprecationWarning",
"ignore::jwt.warnings.InsecureKeyLengthWarning",
] ]
[tool.coverage.run] [tool.coverage.run]

View File

@ -59,7 +59,7 @@ describe('MobileLayout', () => {
</MobileLayout> </MobileLayout>
); );
const main = container.querySelector('main'); const main = container.querySelector('main');
expect(main).toHaveClass('pb-[95px]'); expect(main).toHaveClass('pb-[80px]');
}); });
it('showBottomNav=false 时内容区域无底部 padding', () => { it('showBottomNav=false 时内容区域无底部 padding', () => {

View File

@ -30,7 +30,7 @@ describe('Sidebar', () => {
it('渲染 agency 导航项', () => { it('渲染 agency 导航项', () => {
render(<Sidebar role="agency" />); render(<Sidebar role="agency" />);
expect(screen.getByText('工作台')).toBeInTheDocument(); expect(screen.getByText('工作台')).toBeInTheDocument();
expect(screen.getByText('审核决策')).toBeInTheDocument(); expect(screen.getByText('审核')).toBeInTheDocument();
expect(screen.getByText('Brief 配置')).toBeInTheDocument(); expect(screen.getByText('Brief 配置')).toBeInTheDocument();
expect(screen.getByText('达人管理')).toBeInTheDocument(); expect(screen.getByText('达人管理')).toBeInTheDocument();
expect(screen.getByText('数据报表')).toBeInTheDocument(); expect(screen.getByText('数据报表')).toBeInTheDocument();
@ -38,7 +38,7 @@ describe('Sidebar', () => {
it('渲染 brand 导航项', () => { it('渲染 brand 导航项', () => {
render(<Sidebar role="brand" />); render(<Sidebar role="brand" />);
expect(screen.getByText('数据看板')).toBeInTheDocument(); expect(screen.getByText('项目看板')).toBeInTheDocument();
expect(screen.getByText('AI 配置')).toBeInTheDocument(); expect(screen.getByText('AI 配置')).toBeInTheDocument();
expect(screen.getByText('规则配置')).toBeInTheDocument(); expect(screen.getByText('规则配置')).toBeInTheDocument();
expect(screen.getByText('终审台')).toBeInTheDocument(); expect(screen.getByText('终审台')).toBeInTheDocument();

View File

@ -0,0 +1,520 @@
/**
* AuthContext Unit Tests
*
* Tests the AuthProvider, useAuth hook, and the exported USE_MOCK flag.
* Covers: initial state, login (mock + real), logout, register, and
* restoration of user from localStorage on mount.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
// ---------------------------------------------------------------------------
// Use vi.hoisted to create mock functions before vi.mock hoisting
// ---------------------------------------------------------------------------
const { mockApiLogin, mockApiRegister, mockClearTokens, mockGetAccessToken } = vi.hoisted(() => ({
mockApiLogin: vi.fn(),
mockApiRegister: vi.fn(),
mockClearTokens: vi.fn(),
mockGetAccessToken: vi.fn(() => null),
}))
vi.mock('@/lib/api', () => ({
api: {
login: mockApiLogin,
register: mockApiRegister,
},
clearTokens: mockClearTokens,
getAccessToken: mockGetAccessToken,
setTokens: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Import AuthContext after mocks are set up
// ---------------------------------------------------------------------------
import { AuthProvider, useAuth, USE_MOCK } from './AuthContext'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mockLocalStorage() {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach((k) => delete store[k])
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
}
}
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
)
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AuthContext', () => {
let storage: ReturnType<typeof mockLocalStorage>
beforeEach(() => {
storage = mockLocalStorage()
Object.defineProperty(window, 'localStorage', { value: storage, writable: true })
vi.clearAllMocks()
// Default: no stored token
mockGetAccessToken.mockReturnValue(null)
})
afterEach(() => {
storage.clear()
})
// ---- USE_MOCK flag ----
describe('USE_MOCK export', () => {
it('is exported as a boolean', () => {
expect(typeof USE_MOCK).toBe('boolean')
})
it('is true in test/development environment or based on env var', () => {
// vitest runs with NODE_ENV=test. The AuthContext checks for
// NODE_ENV === 'development' or NEXT_PUBLIC_USE_MOCK === 'true'.
// We verify the export exists and is a boolean.
expect([true, false]).toContain(USE_MOCK)
})
})
// ---- Initial state ----
describe('initial state', () => {
it('starts with user as null', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.user).toBeNull()
})
it('starts with isAuthenticated as false', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.isAuthenticated).toBe(false)
})
it('starts with isLoading true, then becomes false', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
})
})
// ---- Restoring user from localStorage ----
describe('restoring user from localStorage', () => {
it('restores user when token and stored user exist', async () => {
const storedUser = {
id: 'u1',
name: 'Restored User',
email: 'restored@test.com',
role: 'brand',
is_verified: true,
}
mockGetAccessToken.mockReturnValue('valid-token')
storage.setItem('miaosi_user', JSON.stringify(storedUser))
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.user).toEqual(storedUser)
expect(result.current.isAuthenticated).toBe(true)
})
it('does not restore user when token is missing', async () => {
const storedUser = { id: 'u1', name: 'No Token', role: 'brand', is_verified: true }
storage.setItem('miaosi_user', JSON.stringify(storedUser))
mockGetAccessToken.mockReturnValue(null)
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.user).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
})
it('clears invalid JSON from localStorage gracefully', async () => {
mockGetAccessToken.mockReturnValue('some-token')
storage.setItem('miaosi_user', 'not-valid-json{{{')
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(mockClearTokens).toHaveBeenCalled()
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user')
expect(result.current.user).toBeNull()
})
})
// ---- Login ----
describe('login', () => {
// When USE_MOCK is true, the AuthProvider uses mock login logic.
// When false, it calls api.login().
if (!USE_MOCK) {
it('calls api.login and sets user on success (real API path)', async () => {
const loginResponse = {
access_token: 'at',
refresh_token: 'rt',
token_type: 'bearer',
expires_in: 900,
user: {
id: 'u1',
name: 'API User',
email: 'api@test.com',
role: 'brand',
is_verified: true,
},
}
mockApiLogin.mockResolvedValueOnce(loginResponse)
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let loginResult: { success: boolean; error?: string }
await act(async () => {
loginResult = await result.current.login({
email: 'api@test.com',
password: 'pass123',
})
})
expect(loginResult!.success).toBe(true)
expect(result.current.user?.name).toBe('API User')
expect(result.current.isAuthenticated).toBe(true)
expect(storage.setItem).toHaveBeenCalledWith(
'miaosi_user',
expect.stringContaining('API User')
)
})
it('returns error on api.login failure (real API path)', async () => {
mockApiLogin.mockRejectedValueOnce(new Error('Invalid credentials'))
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let loginResult: { success: boolean; error?: string }
await act(async () => {
loginResult = await result.current.login({
email: 'bad@test.com',
password: 'wrong',
})
})
expect(loginResult!.success).toBe(false)
expect(loginResult!.error).toBe('Invalid credentials')
expect(result.current.user).toBeNull()
})
}
if (USE_MOCK) {
it('logs in with mock user for known email (mock path)', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let loginResult: { success: boolean; error?: string }
await act(async () => {
loginResult = await result.current.login({
email: 'creator@demo.com',
password: 'demo123',
})
})
expect(loginResult!.success).toBe(true)
expect(result.current.user?.email).toBe('creator@demo.com')
expect(result.current.isAuthenticated).toBe(true)
})
it('returns error for unknown email in mock mode', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let loginResult: { success: boolean; error?: string }
await act(async () => {
loginResult = await result.current.login({
email: 'unknown@demo.com',
password: 'demo123',
})
})
expect(loginResult!.success).toBe(false)
expect(loginResult!.error).toBeDefined()
})
it('returns error for wrong password in mock mode', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let loginResult: { success: boolean; error?: string }
await act(async () => {
loginResult = await result.current.login({
email: 'creator@demo.com',
password: 'wrongpassword',
})
})
expect(loginResult!.success).toBe(false)
expect(loginResult!.error).toBe('密码错误')
})
it('allows email code login for known user in mock mode', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let loginResult: { success: boolean; error?: string }
await act(async () => {
loginResult = await result.current.login({
email: 'brand@demo.com',
email_code: '123456',
})
})
expect(loginResult!.success).toBe(true)
expect(result.current.user?.email).toBe('brand@demo.com')
expect(result.current.user?.role).toBe('brand')
})
}
})
// ---- Register ----
describe('register', () => {
const registerData = {
email: 'new@example.com',
password: 'secure123',
name: 'New User',
role: 'creator' as const,
email_code: '123456',
}
if (!USE_MOCK) {
it('calls api.register and sets user on success (real API path)', async () => {
const registerResponse = {
access_token: 'at',
refresh_token: 'rt',
token_type: 'bearer',
expires_in: 900,
user: {
id: 'u2',
name: 'New User',
email: 'new@example.com',
role: 'creator',
is_verified: false,
},
}
mockApiRegister.mockResolvedValueOnce(registerResponse)
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let registerResult: { success: boolean; error?: string }
await act(async () => {
registerResult = await result.current.register(registerData)
})
expect(registerResult!.success).toBe(true)
expect(result.current.user?.name).toBe('New User')
expect(result.current.isAuthenticated).toBe(true)
})
it('returns error on api.register failure', async () => {
mockApiRegister.mockRejectedValueOnce(new Error('Email already exists'))
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let registerResult: { success: boolean; error?: string }
await act(async () => {
registerResult = await result.current.register(registerData)
})
expect(registerResult!.success).toBe(false)
expect(registerResult!.error).toBe('Email already exists')
})
}
if (USE_MOCK) {
it('creates a mock user on register in mock mode', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
let registerResult: { success: boolean; error?: string }
await act(async () => {
registerResult = await result.current.register(registerData)
})
expect(registerResult!.success).toBe(true)
expect(result.current.user?.email).toBe('new@example.com')
expect(result.current.user?.name).toBe('New User')
expect(result.current.user?.role).toBe('creator')
expect(result.current.isAuthenticated).toBe(true)
})
}
})
// ---- Logout ----
describe('logout', () => {
it('clears user state, tokens, and localStorage', async () => {
const storedUser = {
id: 'u1',
name: 'Logged In',
email: 'in@test.com',
role: 'brand',
is_verified: true,
}
mockGetAccessToken.mockReturnValue('valid-token')
storage.setItem('miaosi_user', JSON.stringify(storedUser))
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.user).not.toBeNull()
expect(result.current.isAuthenticated).toBe(true)
act(() => {
result.current.logout()
})
expect(result.current.user).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
expect(mockClearTokens).toHaveBeenCalled()
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user')
})
})
// ---- switchRole ----
describe('switchRole', () => {
it('updates the user role and persists to localStorage', async () => {
const storedUser = {
id: 'u1',
name: 'Multi Role',
email: 'multi@test.com',
role: 'brand',
is_verified: true,
}
mockGetAccessToken.mockReturnValue('valid-token')
storage.setItem('miaosi_user', JSON.stringify(storedUser))
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.user?.role).toBe('brand')
act(() => {
result.current.switchRole('agency')
})
expect(result.current.user?.role).toBe('agency')
// Check localStorage was updated with new role
const storedCalls = storage.setItem.mock.calls.filter(
([key]: [string, string]) => key === 'miaosi_user'
)
const lastStored = JSON.parse(storedCalls[storedCalls.length - 1][1])
expect(lastStored.role).toBe('agency')
})
it('does nothing when no user is logged in', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
act(() => {
result.current.switchRole('creator')
})
expect(result.current.user).toBeNull()
})
})
// ---- useAuth outside provider ----
describe('useAuth outside AuthProvider', () => {
it('throws an error when used outside AuthProvider', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
renderHook(() => useAuth())
}).toThrow('useAuth must be used within an AuthProvider')
consoleSpy.mockRestore()
})
})
})

599
frontend/lib/api.test.ts Normal file
View File

@ -0,0 +1,599 @@
/**
* API Client Unit Tests
*
* Tests the ApiClient singleton: axios configuration, token interceptors,
* 401 refresh flow, and key API method endpoint mappings.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ---------------------------------------------------------------------------
// vi.hoisted() runs BEFORE vi.mock hoisting, so these variables are available
// inside the vi.mock factory.
// ---------------------------------------------------------------------------
const {
mockAxiosInstance,
requestInterceptorHolder,
responseErrorInterceptorHolder,
} = vi.hoisted(() => {
const requestInterceptorHolder: { fn: ((config: any) => any) | null } = { fn: null }
const responseErrorInterceptorHolder: { fn: ((error: any) => any) | null } = { fn: null }
const mockAxiosInstance = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
request: {
use: vi.fn((onFulfilled: any) => {
requestInterceptorHolder.fn = onFulfilled
}),
},
response: {
use: vi.fn((_onFulfilled: any, onRejected: any) => {
responseErrorInterceptorHolder.fn = onRejected
}),
},
},
}
return { mockAxiosInstance, requestInterceptorHolder, responseErrorInterceptorHolder }
})
vi.mock('axios', () => {
return {
default: {
create: vi.fn(() => mockAxiosInstance),
post: vi.fn(),
},
}
})
// ---------------------------------------------------------------------------
// Import the module under test AFTER mocking axios
// ---------------------------------------------------------------------------
import axios from 'axios'
import { api, getAccessToken, getRefreshToken, setTokens, clearTokens } from './api'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mockLocalStorage() {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach((k) => delete store[k])
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('API Client', () => {
let storage: ReturnType<typeof mockLocalStorage>
beforeEach(() => {
storage = mockLocalStorage()
Object.defineProperty(window, 'localStorage', { value: storage, writable: true })
// Reset all mock call counts but preserve the interceptor captures
vi.clearAllMocks()
})
afterEach(() => {
storage.clear()
})
// ---- Axios instance configuration ----
describe('axios instance configuration', () => {
it('creates axios instance with correct baseURL and timeout', () => {
// axios.create is called at module load time before vi.clearAllMocks().
// Instead of checking call count, verify the singleton `api` exists and
// that axios.create was invoked (the mock instance is connected).
// We confirm indirectly: if api.login calls mockAxiosInstance.post,
// that proves axios.create returned our mock.
expect(api).toBeDefined()
// Also verify the interceptor callbacks were captured (proves setup ran)
expect(requestInterceptorHolder.fn).not.toBeNull()
expect(responseErrorInterceptorHolder.fn).not.toBeNull()
})
it('captures request and response interceptors during setup', () => {
// The interceptor functions were captured during module initialization.
// Verify they are functions that we can invoke.
expect(typeof requestInterceptorHolder.fn).toBe('function')
expect(typeof responseErrorInterceptorHolder.fn).toBe('function')
})
})
// ---- Token management helpers ----
describe('token management helpers', () => {
it('getAccessToken returns null when no token stored', () => {
expect(getAccessToken()).toBeNull()
})
it('getAccessToken returns the stored token', () => {
storage.setItem('miaosi_access_token', 'test-access-token')
expect(getAccessToken()).toBe('test-access-token')
})
it('getRefreshToken returns null when no token stored', () => {
expect(getRefreshToken()).toBeNull()
})
it('getRefreshToken returns the stored refresh token', () => {
storage.setItem('miaosi_refresh_token', 'test-refresh-token')
expect(getRefreshToken()).toBe('test-refresh-token')
})
it('setTokens stores both access and refresh tokens', () => {
setTokens('access-123', 'refresh-456')
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'access-123')
expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'refresh-456')
})
it('clearTokens removes both tokens from storage', () => {
storage.setItem('miaosi_access_token', 'a')
storage.setItem('miaosi_refresh_token', 'b')
clearTokens()
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token')
})
})
// ---- Request interceptor ----
describe('request interceptor', () => {
it('adds Authorization header when access token exists', () => {
storage.setItem('miaosi_access_token', 'my-token')
const config = { headers: {} as Record<string, string> }
const result = requestInterceptorHolder.fn!(config)
expect(result.headers.Authorization).toBe('Bearer my-token')
})
it('does not add Authorization header when no token', () => {
const config = { headers: {} as Record<string, string> }
const result = requestInterceptorHolder.fn!(config)
expect(result.headers.Authorization).toBeUndefined()
})
it('always adds X-Tenant-ID header', () => {
const config = { headers: {} as Record<string, string> }
const result = requestInterceptorHolder.fn!(config)
expect(result.headers['X-Tenant-ID']).toBeDefined()
})
})
// ---- Response interceptor: 401 refresh ----
describe('response interceptor - 401 refresh', () => {
it('attempts token refresh on 401 response', async () => {
storage.setItem('miaosi_refresh_token', 'valid-refresh')
const newAccessToken = 'new-access-token-123';
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { access_token: newAccessToken, token_type: 'bearer', expires_in: 900 },
})
const error = {
response: { status: 401 },
config: { headers: {} as Record<string, string>, _retry: false },
}
// The response interceptor will:
// 1. Call axios.post to refresh the token
// 2. Try to call this.client(originalRequest) to retry
// Since mockAxiosInstance is an object (not callable), step 2 throws.
// We catch that and verify step 1 happened correctly.
try {
await responseErrorInterceptorHolder.fn!(error)
} catch {
// Expected: this.client is not a function (mockAxiosInstance is not callable)
}
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/auth/refresh'),
{ refresh_token: 'valid-refresh' }
)
// Verify the new access token was stored
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', newAccessToken)
})
it('clears tokens and redirects to /login when refresh fails', async () => {
storage.setItem('miaosi_refresh_token', 'expired-refresh')
;(axios.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Token expired'))
const originalHref = window.location.href
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
configurable: true,
})
const error = {
response: { status: 401 },
config: { headers: {} as Record<string, string>, _retry: false },
}
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow()
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
expect(window.location.href).toBe('/login')
// Restore
Object.defineProperty(window, 'location', {
value: { href: originalHref },
writable: true,
configurable: true,
})
})
it('rejects with error message for non-401 errors', async () => {
const error = {
response: { status: 500, data: { detail: 'Internal Server Error' } },
config: { headers: {} as Record<string, string> },
message: 'Request failed',
}
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow('Internal Server Error')
})
it('falls through to error handler when _retry is already true', async () => {
const error = {
response: { status: 401 },
config: { headers: {} as Record<string, string>, _retry: true },
message: 'Unauthorized',
}
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow()
expect(axios.post).not.toHaveBeenCalled()
})
it('rejects when no refresh token is available', async () => {
// No refresh token in storage
const error = {
response: { status: 401 },
config: { headers: {} as Record<string, string>, _retry: false },
}
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
configurable: true,
})
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow()
// refresh should not have been called since there's no token
expect(axios.post).not.toHaveBeenCalled()
})
})
// ---- API methods: endpoint mapping ----
describe('api.login()', () => {
it('calls POST /auth/login with credentials and stores tokens', async () => {
const mockResponse = {
data: {
access_token: 'at',
refresh_token: 'rt',
token_type: 'bearer',
expires_in: 900,
user: { id: 'u1', name: 'Test', role: 'brand', is_verified: true },
},
}
mockAxiosInstance.post.mockResolvedValueOnce(mockResponse)
const result = await api.login({ email: 'test@example.com', password: 'pass123' })
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/login', {
email: 'test@example.com',
password: 'pass123',
})
expect(result.access_token).toBe('at')
expect(result.user.name).toBe('Test')
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'at')
expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'rt')
})
})
describe('api.register()', () => {
it('calls POST /auth/register with user data and stores tokens', async () => {
const registerData = {
email: 'new@example.com',
password: 'secure123',
name: 'New User',
role: 'creator' as const,
email_code: '123456',
}
const mockResponse = {
data: {
access_token: 'new-at',
refresh_token: 'new-rt',
token_type: 'bearer',
expires_in: 900,
user: { id: 'u2', name: 'New User', email: 'new@example.com', role: 'creator', is_verified: false },
},
}
mockAxiosInstance.post.mockResolvedValueOnce(mockResponse)
const result = await api.register(registerData)
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/register', registerData)
expect(result.user.email).toBe('new@example.com')
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'new-at')
expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'new-rt')
})
})
describe('api.listTasks()', () => {
it('calls GET /tasks with pagination params', async () => {
mockAxiosInstance.get.mockResolvedValueOnce({
data: { items: [], total: 0, page: 1, page_size: 20 },
})
const result = await api.listTasks(1, 20)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/tasks', {
params: { page: 1, page_size: 20, stage: undefined },
})
expect(result.total).toBe(0)
})
it('passes stage filter when provided', async () => {
mockAxiosInstance.get.mockResolvedValueOnce({
data: { items: [], total: 0, page: 1, page_size: 10 },
})
await api.listTasks(1, 10, 'script_upload' as any)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/tasks', {
params: { page: 1, page_size: 10, stage: 'script_upload' },
})
})
})
describe('api.getUploadPolicy()', () => {
it('calls POST /upload/policy with file type', async () => {
mockAxiosInstance.post.mockResolvedValueOnce({
data: {
x_tos_algorithm: 'algo',
x_tos_credential: 'cred',
x_tos_date: '2024-01-01',
x_tos_signature: 'sig',
policy: 'base64policy',
host: 'https://oss.example.com',
dir: 'uploads/',
expire: 3600,
max_size_mb: 100,
},
})
const result = await api.getUploadPolicy('video')
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/upload/policy', {
file_type: 'video',
})
expect(result.host).toBe('https://oss.example.com')
})
it('defaults to "general" file type when not specified', async () => {
mockAxiosInstance.post.mockResolvedValueOnce({ data: {} })
await api.getUploadPolicy()
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/upload/policy', {
file_type: 'general',
})
})
})
describe('api.getProfile()', () => {
it('calls GET /profile', async () => {
mockAxiosInstance.get.mockResolvedValueOnce({
data: {
id: 'u1',
name: 'Test User',
email: 'test@example.com',
role: 'brand',
is_verified: true,
},
})
const result = await api.getProfile()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/profile')
expect(result.name).toBe('Test User')
})
})
describe('api.getMessages()', () => {
it('calls GET /messages with params', async () => {
mockAxiosInstance.get.mockResolvedValueOnce({
data: { items: [], total: 0, page: 1, page_size: 20 },
})
const params = { page: 1, page_size: 20, is_read: false }
const result = await api.getMessages(params)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages', { params })
expect(result.total).toBe(0)
})
it('calls GET /messages without params when none provided', async () => {
mockAxiosInstance.get.mockResolvedValueOnce({
data: { items: [], total: 0, page: 1, page_size: 20 },
})
await api.getMessages()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages', { params: undefined })
})
})
describe('api.getUnreadCount()', () => {
it('calls GET /messages/unread-count', async () => {
mockAxiosInstance.get.mockResolvedValueOnce({ data: { count: 5 } })
const result = await api.getUnreadCount()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages/unread-count')
expect(result.count).toBe(5)
})
})
describe('api.logout()', () => {
it('calls POST /auth/logout and clears tokens', async () => {
storage.setItem('miaosi_access_token', 'at')
storage.setItem('miaosi_refresh_token', 'rt')
mockAxiosInstance.post.mockResolvedValueOnce({ data: undefined })
await api.logout()
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/logout')
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token')
})
it('clears tokens even when POST /auth/logout fails', async () => {
mockAxiosInstance.post.mockRejectedValueOnce(new Error('Network error'))
await api.logout().catch(() => {})
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token')
})
})
describe('api.sendEmailCode()', () => {
it('calls POST /auth/send-code', async () => {
mockAxiosInstance.post.mockResolvedValueOnce({
data: { message: 'Code sent', expires_in: 300 },
})
const result = await api.sendEmailCode({ email: 'a@b.com', purpose: 'login' })
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/send-code', {
email: 'a@b.com',
purpose: 'login',
})
expect(result.expires_in).toBe(300)
})
})
describe('api.resetPassword()', () => {
it('calls POST /auth/reset-password', async () => {
mockAxiosInstance.post.mockResolvedValueOnce({
data: { message: 'Password reset' },
})
const result = await api.resetPassword({
email: 'a@b.com',
email_code: '123456',
new_password: 'newpass',
})
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/reset-password', {
email: 'a@b.com',
email_code: '123456',
new_password: 'newpass',
})
expect(result.message).toBe('Password reset')
})
})
describe('api.updateProfile()', () => {
it('calls PUT /profile with update data', async () => {
const updateData = { name: 'Updated Name' }
mockAxiosInstance.put.mockResolvedValueOnce({
data: { id: 'u1', name: 'Updated Name', role: 'brand', is_verified: true },
})
const result = await api.updateProfile(updateData)
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/profile', updateData)
expect(result.name).toBe('Updated Name')
})
})
describe('api.changePassword()', () => {
it('calls PUT /profile/password', async () => {
mockAxiosInstance.put.mockResolvedValueOnce({
data: { message: 'Password changed' },
})
const result = await api.changePassword({
old_password: 'old',
new_password: 'new',
})
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/profile/password', {
old_password: 'old',
new_password: 'new',
})
expect(result.message).toBe('Password changed')
})
})
describe('api.markMessageAsRead()', () => {
it('calls PUT /messages/:id/read', async () => {
mockAxiosInstance.put.mockResolvedValueOnce({ data: undefined })
await api.markMessageAsRead('msg-001')
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/messages/msg-001/read')
})
})
describe('api.markAllMessagesAsRead()', () => {
it('calls PUT /messages/read-all', async () => {
mockAxiosInstance.put.mockResolvedValueOnce({ data: undefined })
await api.markAllMessagesAsRead()
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/messages/read-all')
})
})
describe('api.setTenantId()', () => {
it('updates the tenant ID used in subsequent requests', () => {
api.setTenantId('BR002')
const config = { headers: {} as Record<string, string> }
const result = requestInterceptorHolder.fn!(config)
expect(result.headers['X-Tenant-ID']).toBe('BR002')
// Reset for other tests
api.setTenantId('default')
})
})
describe('api.healthCheck()', () => {
it('calls GET /health', async () => {
mockAxiosInstance.get.mockResolvedValueOnce({
data: { status: 'ok', version: '1.0.0' },
})
const result = await api.healthCheck()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/health')
expect(result.status).toBe('ok')
})
})
})

View File

@ -0,0 +1,802 @@
import { describe, it, expect } from 'vitest'
import { mapTaskToUI } from './taskStageMapper'
import type { TaskResponse, TaskStage, TaskStatus } from '@/types/task'
/**
* Helper: create a minimal TaskResponse with sensible defaults.
* Pass overrides for fields you care about.
*/
function mockTask(overrides: Partial<TaskResponse> = {}): TaskResponse {
return {
id: 'TK000001',
name: '测试任务',
sequence: 1,
stage: 'script_upload' as TaskStage,
project: { id: 'PJ000001', name: '测试项目' },
agency: { id: 'AG000001', name: '测试代理商' },
creator: { id: 'CR000001', name: '测试达人' },
script_file_url: null,
script_file_name: null,
script_uploaded_at: null,
script_ai_score: null,
script_ai_result: null,
script_agency_status: null,
script_agency_comment: null,
script_brand_status: null,
script_brand_comment: null,
video_file_url: null,
video_file_name: null,
video_duration: null,
video_thumbnail_url: null,
video_uploaded_at: null,
video_ai_score: null,
video_ai_result: null,
video_agency_status: null,
video_agency_comment: null,
video_brand_status: null,
video_brand_comment: null,
appeal_count: 0,
is_appeal: false,
appeal_reason: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
// ---------------------------------------------------------------------------
// 1. script_upload stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — script_upload', () => {
const task = mockTask({ stage: 'script_upload' })
const ui = mapTaskToUI(task)
it('scriptStage.submit should be "current"', () => {
expect(ui.scriptStage.submit).toBe('current')
})
it('remaining script steps should be "pending"', () => {
expect(ui.scriptStage.ai).toBe('pending')
expect(ui.scriptStage.agency).toBe('pending')
expect(ui.scriptStage.brand).toBe('pending')
})
it('all video steps should be "pending"', () => {
expect(ui.videoStage.submit).toBe('pending')
expect(ui.videoStage.ai).toBe('pending')
expect(ui.videoStage.agency).toBe('pending')
expect(ui.videoStage.brand).toBe('pending')
})
it('currentPhase should be "script"', () => {
expect(ui.currentPhase).toBe('script')
})
it('buttonText should be "上传脚本"', () => {
expect(ui.buttonText).toBe('上传脚本')
})
it('buttonType should be "primary"', () => {
expect(ui.buttonType).toBe('primary')
})
it('statusLabel should be "待上传"', () => {
expect(ui.statusLabel).toBe('待上传')
})
it('filterCategory should be "pending"', () => {
expect(ui.filterCategory).toBe('pending')
})
it('scriptColor should be "blue" (no errors, brand not done)', () => {
expect(ui.scriptColor).toBe('blue')
})
it('videoColor should be "blue"', () => {
expect(ui.videoColor).toBe('blue')
})
})
// ---------------------------------------------------------------------------
// 2. script_ai_review stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — script_ai_review', () => {
it('ai step should be "current" when no result yet', () => {
const task = mockTask({ stage: 'script_ai_review', script_ai_result: null })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.submit).toBe('done')
expect(ui.scriptStage.ai).toBe('current')
expect(ui.scriptStage.agency).toBe('pending')
expect(ui.scriptStage.brand).toBe('pending')
})
it('ai step should be "done" when result is present', () => {
const task = mockTask({
stage: 'script_ai_review',
script_ai_result: { score: 85, violations: [], soft_warnings: [] },
})
const ui = mapTaskToUI(task)
expect(ui.scriptStage.ai).toBe('done')
})
it('statusLabel should be "AI 审核中"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
expect(ui.statusLabel).toBe('AI 审核中')
})
it('buttonText should be "审核中" and buttonType "disabled"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
expect(ui.buttonText).toBe('审核中')
expect(ui.buttonType).toBe('disabled')
})
it('filterCategory should be "reviewing"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
expect(ui.filterCategory).toBe('reviewing')
})
it('currentPhase should be "script"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
expect(ui.currentPhase).toBe('script')
})
})
// ---------------------------------------------------------------------------
// 3. script_agency_review stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — script_agency_review', () => {
it('agency step defaults to "current" when status is null/pending', () => {
const task = mockTask({ stage: 'script_agency_review' })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.submit).toBe('done')
expect(ui.scriptStage.ai).toBe('done')
expect(ui.scriptStage.agency).toBe('current')
expect(ui.scriptStage.brand).toBe('pending')
})
it('agency step is "current" when status is "processing"', () => {
const task = mockTask({ stage: 'script_agency_review', script_agency_status: 'processing' })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.agency).toBe('current')
})
it('agency step is "done" when status is "passed"', () => {
const task = mockTask({ stage: 'script_agency_review', script_agency_status: 'passed' })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.agency).toBe('done')
})
it('agency step is "done" when status is "force_passed"', () => {
const task = mockTask({ stage: 'script_agency_review', script_agency_status: 'force_passed' })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.agency).toBe('done')
})
it('statusLabel should be "代理商审核中"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_agency_review' }))
expect(ui.statusLabel).toBe('代理商审核中')
})
it('buttonText should be "审核中", buttonType "disabled"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_agency_review' }))
expect(ui.buttonText).toBe('审核中')
expect(ui.buttonType).toBe('disabled')
})
it('filterCategory should be "reviewing"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_agency_review' }))
expect(ui.filterCategory).toBe('reviewing')
})
})
// ---------------------------------------------------------------------------
// 4. script_brand_review stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — script_brand_review', () => {
it('brand step defaults to "current" when status is null/pending', () => {
const task = mockTask({ stage: 'script_brand_review' })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.submit).toBe('done')
expect(ui.scriptStage.ai).toBe('done')
expect(ui.scriptStage.agency).toBe('done')
expect(ui.scriptStage.brand).toBe('current')
})
it('brand step is "current" when status is "processing"', () => {
const task = mockTask({ stage: 'script_brand_review', script_brand_status: 'processing' })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.brand).toBe('current')
})
it('brand step is "done" when status is "passed"', () => {
const task = mockTask({ stage: 'script_brand_review', script_brand_status: 'passed' })
const ui = mapTaskToUI(task)
expect(ui.scriptStage.brand).toBe('done')
})
it('statusLabel should be "品牌方审核中"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_brand_review' }))
expect(ui.statusLabel).toBe('品牌方审核中')
})
it('buttonText should be "审核中", buttonType "disabled"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_brand_review' }))
expect(ui.buttonText).toBe('审核中')
expect(ui.buttonType).toBe('disabled')
})
it('filterCategory should be "reviewing"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'script_brand_review' }))
expect(ui.filterCategory).toBe('reviewing')
})
})
// ---------------------------------------------------------------------------
// 5. video_upload stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — video_upload', () => {
const task = mockTask({ stage: 'video_upload' })
const ui = mapTaskToUI(task)
it('all script steps should be "done"', () => {
expect(ui.scriptStage.submit).toBe('done')
expect(ui.scriptStage.ai).toBe('done')
expect(ui.scriptStage.agency).toBe('done')
expect(ui.scriptStage.brand).toBe('done')
})
it('videoStage.submit should be "current"', () => {
expect(ui.videoStage.submit).toBe('current')
})
it('remaining video steps should be "pending"', () => {
expect(ui.videoStage.ai).toBe('pending')
expect(ui.videoStage.agency).toBe('pending')
expect(ui.videoStage.brand).toBe('pending')
})
it('currentPhase should be "video"', () => {
expect(ui.currentPhase).toBe('video')
})
it('buttonText should be "上传视频"', () => {
expect(ui.buttonText).toBe('上传视频')
})
it('buttonType should be "primary"', () => {
expect(ui.buttonType).toBe('primary')
})
it('statusLabel should be "待上传"', () => {
expect(ui.statusLabel).toBe('待上传')
})
it('filterCategory should be "pending"', () => {
expect(ui.filterCategory).toBe('pending')
})
it('scriptColor should be "green" (brand done)', () => {
expect(ui.scriptColor).toBe('green')
})
})
// ---------------------------------------------------------------------------
// 6. video_ai_review stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — video_ai_review', () => {
it('video ai step should be "current" when no result', () => {
const task = mockTask({ stage: 'video_ai_review', video_ai_result: null })
const ui = mapTaskToUI(task)
expect(ui.videoStage.submit).toBe('done')
expect(ui.videoStage.ai).toBe('current')
expect(ui.videoStage.agency).toBe('pending')
expect(ui.videoStage.brand).toBe('pending')
})
it('video ai step should be "done" when result is present', () => {
const task = mockTask({
stage: 'video_ai_review',
video_ai_result: { score: 90, violations: [], soft_warnings: [] },
})
const ui = mapTaskToUI(task)
expect(ui.videoStage.ai).toBe('done')
})
it('currentPhase should be "video"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
expect(ui.currentPhase).toBe('video')
})
it('all script steps should be "done"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
expect(ui.scriptStage.submit).toBe('done')
expect(ui.scriptStage.ai).toBe('done')
expect(ui.scriptStage.agency).toBe('done')
expect(ui.scriptStage.brand).toBe('done')
})
it('statusLabel should be "AI 审核中"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
expect(ui.statusLabel).toBe('AI 审核中')
})
it('buttonText should be "审核中", buttonType "disabled"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
expect(ui.buttonText).toBe('审核中')
expect(ui.buttonType).toBe('disabled')
})
it('filterCategory should be "reviewing"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
expect(ui.filterCategory).toBe('reviewing')
})
})
// ---------------------------------------------------------------------------
// 7. video_agency_review stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — video_agency_review', () => {
it('video agency step defaults to "current" when status is null', () => {
const task = mockTask({ stage: 'video_agency_review' })
const ui = mapTaskToUI(task)
expect(ui.videoStage.submit).toBe('done')
expect(ui.videoStage.ai).toBe('done')
expect(ui.videoStage.agency).toBe('current')
expect(ui.videoStage.brand).toBe('pending')
})
it('video agency step is "current" when status is "processing"', () => {
const task = mockTask({ stage: 'video_agency_review', video_agency_status: 'processing' })
const ui = mapTaskToUI(task)
expect(ui.videoStage.agency).toBe('current')
})
it('video agency step is "done" when status is "passed"', () => {
const task = mockTask({ stage: 'video_agency_review', video_agency_status: 'passed' })
const ui = mapTaskToUI(task)
expect(ui.videoStage.agency).toBe('done')
})
it('currentPhase should be "video"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
expect(ui.currentPhase).toBe('video')
})
it('statusLabel should be "代理商审核中"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
expect(ui.statusLabel).toBe('代理商审核中')
})
it('buttonText should be "审核中", buttonType "disabled"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
expect(ui.buttonText).toBe('审核中')
expect(ui.buttonType).toBe('disabled')
})
it('filterCategory should be "reviewing"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
expect(ui.filterCategory).toBe('reviewing')
})
})
// ---------------------------------------------------------------------------
// 8. video_brand_review stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — video_brand_review', () => {
it('video brand step defaults to "current" when status is null', () => {
const task = mockTask({ stage: 'video_brand_review' })
const ui = mapTaskToUI(task)
expect(ui.videoStage.submit).toBe('done')
expect(ui.videoStage.ai).toBe('done')
expect(ui.videoStage.agency).toBe('done')
expect(ui.videoStage.brand).toBe('current')
})
it('video brand step is "current" when status is "processing"', () => {
const task = mockTask({ stage: 'video_brand_review', video_brand_status: 'processing' })
const ui = mapTaskToUI(task)
expect(ui.videoStage.brand).toBe('current')
})
it('video brand step is "done" when status is "passed"', () => {
const task = mockTask({ stage: 'video_brand_review', video_brand_status: 'passed' })
const ui = mapTaskToUI(task)
expect(ui.videoStage.brand).toBe('done')
})
it('currentPhase should be "video"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_brand_review' }))
expect(ui.currentPhase).toBe('video')
})
it('statusLabel should be "品牌方审核中"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_brand_review' }))
expect(ui.statusLabel).toBe('品牌方审核中')
})
it('filterCategory should be "reviewing"', () => {
const ui = mapTaskToUI(mockTask({ stage: 'video_brand_review' }))
expect(ui.filterCategory).toBe('reviewing')
})
})
// ---------------------------------------------------------------------------
// 9. completed stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — completed', () => {
const task = mockTask({ stage: 'completed' })
const ui = mapTaskToUI(task)
it('all script steps should be "done"', () => {
expect(ui.scriptStage.submit).toBe('done')
expect(ui.scriptStage.ai).toBe('done')
expect(ui.scriptStage.agency).toBe('done')
expect(ui.scriptStage.brand).toBe('done')
})
it('all video steps should be "done"', () => {
expect(ui.videoStage.submit).toBe('done')
expect(ui.videoStage.ai).toBe('done')
expect(ui.videoStage.agency).toBe('done')
expect(ui.videoStage.brand).toBe('done')
})
it('currentPhase should be "completed"', () => {
expect(ui.currentPhase).toBe('completed')
})
it('buttonText should be "已完成"', () => {
expect(ui.buttonText).toBe('已完成')
})
it('buttonType should be "success"', () => {
expect(ui.buttonType).toBe('success')
})
it('statusLabel should be "已完成"', () => {
expect(ui.statusLabel).toBe('已完成')
})
it('filterCategory should be "completed"', () => {
expect(ui.filterCategory).toBe('completed')
})
it('scriptColor should be "green"', () => {
expect(ui.scriptColor).toBe('green')
})
it('videoColor should be "green"', () => {
expect(ui.videoColor).toBe('green')
})
})
// ---------------------------------------------------------------------------
// 10. rejected stage
// ---------------------------------------------------------------------------
describe('mapTaskToUI — rejected', () => {
const task = mockTask({ stage: 'rejected' })
const ui = mapTaskToUI(task)
it('buttonText should be "重新提交"', () => {
expect(ui.buttonText).toBe('重新提交')
})
it('buttonType should be "warning"', () => {
expect(ui.buttonType).toBe('warning')
})
it('statusLabel should be "已驳回"', () => {
expect(ui.statusLabel).toBe('已驳回')
})
it('filterCategory should be "rejected"', () => {
expect(ui.filterCategory).toBe('rejected')
})
it('all script steps should be "done" (stageIndex >= 4)', () => {
expect(ui.scriptStage.submit).toBe('done')
expect(ui.scriptStage.ai).toBe('done')
expect(ui.scriptStage.agency).toBe('done')
expect(ui.scriptStage.brand).toBe('done')
})
})
// ---------------------------------------------------------------------------
// 11. Script agency rejected
// ---------------------------------------------------------------------------
describe('mapTaskToUI — script agency rejected', () => {
it('scriptStage.agency should be "error" even during script_agency_review', () => {
const task = mockTask({
stage: 'script_agency_review',
script_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.scriptStage.agency).toBe('error')
})
it('scriptColor should be "red" when agency is error', () => {
const task = mockTask({
stage: 'script_agency_review',
script_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.scriptColor).toBe('red')
})
it('filterCategory should be "rejected" when agency rejected', () => {
const task = mockTask({
stage: 'script_agency_review',
script_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe('rejected')
})
})
// ---------------------------------------------------------------------------
// 12. Script brand rejected
// ---------------------------------------------------------------------------
describe('mapTaskToUI — script brand rejected', () => {
it('scriptStage.brand should be "error"', () => {
const task = mockTask({
stage: 'script_brand_review',
script_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.scriptStage.brand).toBe('error')
})
it('scriptColor should be "red" when brand is error', () => {
const task = mockTask({
stage: 'script_brand_review',
script_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.scriptColor).toBe('red')
})
it('filterCategory should be "rejected"', () => {
const task = mockTask({
stage: 'script_brand_review',
script_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe('rejected')
})
})
// ---------------------------------------------------------------------------
// 13. Video agency rejected
// ---------------------------------------------------------------------------
describe('mapTaskToUI — video agency rejected', () => {
it('videoStage.agency should be "error"', () => {
const task = mockTask({
stage: 'video_agency_review',
video_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.videoStage.agency).toBe('error')
})
it('videoColor should be "red" when video agency is error', () => {
const task = mockTask({
stage: 'video_agency_review',
video_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.videoColor).toBe('red')
})
it('filterCategory should be "rejected"', () => {
const task = mockTask({
stage: 'video_agency_review',
video_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe('rejected')
})
})
// ---------------------------------------------------------------------------
// 13b. Video brand rejected
// ---------------------------------------------------------------------------
describe('mapTaskToUI — video brand rejected', () => {
it('videoStage.brand should be "error"', () => {
const task = mockTask({
stage: 'video_brand_review',
video_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.videoStage.brand).toBe('error')
})
it('videoColor should be "red"', () => {
const task = mockTask({
stage: 'video_brand_review',
video_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.videoColor).toBe('red')
})
it('filterCategory should be "rejected"', () => {
const task = mockTask({
stage: 'video_brand_review',
video_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe('rejected')
})
})
// ---------------------------------------------------------------------------
// 14. filterCategory across different stages
// ---------------------------------------------------------------------------
describe('mapTaskToUI — filterCategory for different stages', () => {
const cases: Array<{ stage: TaskStage; expected: string; desc: string }> = [
{ stage: 'script_upload', expected: 'pending', desc: 'script_upload => pending' },
{ stage: 'video_upload', expected: 'pending', desc: 'video_upload => pending' },
{ stage: 'script_ai_review', expected: 'reviewing', desc: 'script_ai_review => reviewing' },
{ stage: 'script_agency_review', expected: 'reviewing', desc: 'script_agency_review => reviewing' },
{ stage: 'script_brand_review', expected: 'reviewing', desc: 'script_brand_review => reviewing' },
{ stage: 'video_ai_review', expected: 'reviewing', desc: 'video_ai_review => reviewing' },
{ stage: 'video_agency_review', expected: 'reviewing', desc: 'video_agency_review => reviewing' },
{ stage: 'video_brand_review', expected: 'reviewing', desc: 'video_brand_review => reviewing' },
{ stage: 'completed', expected: 'completed', desc: 'completed => completed' },
{ stage: 'rejected', expected: 'rejected', desc: 'rejected => rejected' },
]
cases.forEach(({ stage, expected, desc }) => {
it(desc, () => {
const task = mockTask({ stage })
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe(expected)
})
})
})
// ---------------------------------------------------------------------------
// 15. Rejection override: any rejected status forces filterCategory to rejected
// ---------------------------------------------------------------------------
describe('mapTaskToUI — rejection status overrides filterCategory', () => {
it('video_brand_review with script_agency_status=rejected => filterCategory=rejected', () => {
const task = mockTask({
stage: 'video_brand_review',
script_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe('rejected')
})
it('video_ai_review with video_agency_status=rejected => filterCategory=rejected', () => {
const task = mockTask({
stage: 'video_ai_review',
video_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe('rejected')
})
it('completed stage ignores past rejections (filterCategory stays completed)', () => {
// The source code: if (stage !== 'completed') filterCategory = 'rejected'
const task = mockTask({
stage: 'completed',
script_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.filterCategory).toBe('completed')
})
})
// ---------------------------------------------------------------------------
// 16. statusToStep — internal mapping via statusToStep through mapTaskToUI
// ---------------------------------------------------------------------------
describe('mapTaskToUI — statusToStep mapping via agency/brand statuses', () => {
it('force_passed maps to done', () => {
const task = mockTask({
stage: 'script_brand_review',
script_brand_status: 'force_passed',
})
const ui = mapTaskToUI(task)
expect(ui.scriptStage.brand).toBe('done')
})
it('processing maps to current', () => {
const task = mockTask({
stage: 'video_brand_review',
video_brand_status: 'processing',
})
const ui = mapTaskToUI(task)
expect(ui.videoStage.brand).toBe('current')
})
it('null status defaults to current (pending fallback to current)', () => {
const task = mockTask({
stage: 'video_agency_review',
video_agency_status: null,
})
const ui = mapTaskToUI(task)
expect(ui.videoStage.agency).toBe('current')
})
})
// ---------------------------------------------------------------------------
// 17. Color logic
// ---------------------------------------------------------------------------
describe('mapTaskToUI — color logic', () => {
it('scriptColor is "green" when script brand is done and no errors', () => {
const task = mockTask({ stage: 'video_upload' })
const ui = mapTaskToUI(task)
expect(ui.scriptColor).toBe('green')
})
it('scriptColor is "blue" when script brand is not yet done and no errors', () => {
const task = mockTask({ stage: 'script_ai_review' })
const ui = mapTaskToUI(task)
expect(ui.scriptColor).toBe('blue')
})
it('scriptColor is "red" when script agency has error', () => {
const task = mockTask({
stage: 'script_agency_review',
script_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.scriptColor).toBe('red')
})
it('scriptColor is "red" when script brand has error', () => {
const task = mockTask({
stage: 'script_brand_review',
script_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.scriptColor).toBe('red')
})
it('videoColor is "green" when completed', () => {
const task = mockTask({ stage: 'completed' })
const ui = mapTaskToUI(task)
expect(ui.videoColor).toBe('green')
})
it('videoColor is "blue" when video is in progress with no errors', () => {
const task = mockTask({ stage: 'video_ai_review' })
const ui = mapTaskToUI(task)
expect(ui.videoColor).toBe('blue')
})
it('videoColor is "red" when video brand has error', () => {
const task = mockTask({
stage: 'video_brand_review',
video_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.videoColor).toBe('red')
})
})
// ---------------------------------------------------------------------------
// 18. Edge case: rejected stage with various rejection sources
// ---------------------------------------------------------------------------
describe('mapTaskToUI — rejected stage with rejection source details', () => {
it('rejected stage with script_brand_status=rejected shows error on script brand', () => {
const task = mockTask({
stage: 'rejected',
script_brand_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.scriptStage.brand).toBe('error')
expect(ui.scriptColor).toBe('red')
})
it('rejected stage with video_agency_status=rejected shows error on video agency', () => {
const task = mockTask({
stage: 'rejected',
video_agency_status: 'rejected',
})
const ui = mapTaskToUI(task)
expect(ui.videoStage.agency).toBe('error')
expect(ui.videoColor).toBe('red')
})
})