diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..49a282b
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -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
diff --git a/backend/app/middleware/rate_limit.py b/backend/app/middleware/rate_limit.py
index 163ff7d..7773f29 100644
--- a/backend/app/middleware/rate_limit.py
+++ b/backend/app/middleware/rate_limit.py
@@ -1,6 +1,6 @@
"""
-简单的速率限制中间件
-基于内存的滑动窗口计数器
+速率限制中间件
+基于内存的滑动窗口计数器,支持按路径自定义限制和标准响应头。
"""
import time
from collections import defaultdict
@@ -14,50 +14,111 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
速率限制中间件
- 默认: 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)
self.default_limit = default_limit
self.window_seconds = window_seconds
self.requests: dict[str, list[float]] = defaultdict(list)
- # Stricter limits for auth endpoints
- self.strict_paths = {"/api/v1/auth/login", "/api/v1/auth/register"}
- self.strict_limit = 10
+ # Merge caller-supplied overrides on top of the built-in defaults.
+ self.path_limits: dict[str, int] = {**self.DEFAULT_PATH_LIMITS}
+ 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):
client_ip = request.client.host if request.client else "unknown"
path = request.url.path
now = time.time()
- # Determine rate limit
- if path in self.strict_paths:
- key = f"{client_ip}:{path}"
- limit = self.strict_limit
- else:
- key = client_ip
- limit = self.default_limit
+ limit = self._get_limit(path)
+ key = self._make_key(client_ip, path)
- # Clean old entries
+ # Clean old entries outside the sliding window
window_start = now - self.window_seconds
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
- if len(self.requests[key]) >= limit:
+ if current_count >= limit:
return JSONResponse(
status_code=429,
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
self.requests[key].append(now)
- # Periodic cleanup (every 1000 requests to this key)
+ # Periodic cleanup (keep memory bounded)
if len(self.requests) > 10000:
self._cleanup(now)
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
def _cleanup(self, now: float):
diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py
index e4c6bc2..97bfca5 100644
--- a/backend/app/services/auth.py
+++ b/backend/app/services/auth.py
@@ -4,7 +4,8 @@
from datetime import datetime, timedelta
from typing import Optional
import secrets
-from jose import jwt, JWTError
+import jwt
+from jwt.exceptions import PyJWTError as JWTError
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 8a0abe0..543b4e0 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -14,7 +14,7 @@ dependencies = [
"httpx>=0.26.0",
"pydantic[email]>=2.5.0",
"pydantic-settings>=2.0.0",
- "python-jose>=3.3.0",
+ "PyJWT>=2.8.0",
"passlib>=1.7.4",
"alembic>=1.13.0",
"cryptography>=42.0.0",
@@ -57,6 +57,7 @@ markers = [
]
filterwarnings = [
"ignore::DeprecationWarning",
+ "ignore::jwt.warnings.InsecureKeyLengthWarning",
]
[tool.coverage.run]
diff --git a/frontend/components/layout/MobileLayout.test.tsx b/frontend/components/layout/MobileLayout.test.tsx
index 9983bb2..5edfafa 100644
--- a/frontend/components/layout/MobileLayout.test.tsx
+++ b/frontend/components/layout/MobileLayout.test.tsx
@@ -59,7 +59,7 @@ describe('MobileLayout', () => {
);
const main = container.querySelector('main');
- expect(main).toHaveClass('pb-[95px]');
+ expect(main).toHaveClass('pb-[80px]');
});
it('showBottomNav=false 时内容区域无底部 padding', () => {
diff --git a/frontend/components/navigation/Sidebar.test.tsx b/frontend/components/navigation/Sidebar.test.tsx
index e0f657a..422b465 100644
--- a/frontend/components/navigation/Sidebar.test.tsx
+++ b/frontend/components/navigation/Sidebar.test.tsx
@@ -30,7 +30,7 @@ describe('Sidebar', () => {
it('渲染 agency 导航项', () => {
render();
expect(screen.getByText('工作台')).toBeInTheDocument();
- expect(screen.getByText('审核决策')).toBeInTheDocument();
+ expect(screen.getByText('审核台')).toBeInTheDocument();
expect(screen.getByText('Brief 配置')).toBeInTheDocument();
expect(screen.getByText('达人管理')).toBeInTheDocument();
expect(screen.getByText('数据报表')).toBeInTheDocument();
@@ -38,7 +38,7 @@ describe('Sidebar', () => {
it('渲染 brand 导航项', () => {
render();
- expect(screen.getByText('数据看板')).toBeInTheDocument();
+ expect(screen.getByText('项目看板')).toBeInTheDocument();
expect(screen.getByText('AI 配置')).toBeInTheDocument();
expect(screen.getByText('规则配置')).toBeInTheDocument();
expect(screen.getByText('终审台')).toBeInTheDocument();
diff --git a/frontend/contexts/AuthContext.test.tsx b/frontend/contexts/AuthContext.test.tsx
new file mode 100644
index 0000000..d44d48d
--- /dev/null
+++ b/frontend/contexts/AuthContext.test.tsx
@@ -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 = {}
+ 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 }) => (
+ {children}
+)
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('AuthContext', () => {
+ let storage: ReturnType
+
+ 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()
+ })
+ })
+})
diff --git a/frontend/lib/api.test.ts b/frontend/lib/api.test.ts
new file mode 100644
index 0000000..08f0d11
--- /dev/null
+++ b/frontend/lib/api.test.ts
@@ -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 = {}
+ 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
+
+ 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 }
+ 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 }
+ const result = requestInterceptorHolder.fn!(config)
+ expect(result.headers.Authorization).toBeUndefined()
+ })
+
+ it('always adds X-Tenant-ID header', () => {
+ const config = { headers: {} as Record }
+ 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).mockResolvedValueOnce({
+ data: { access_token: newAccessToken, token_type: 'bearer', expires_in: 900 },
+ })
+
+ const error = {
+ response: { status: 401 },
+ config: { headers: {} as Record, _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).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, _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 },
+ 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, _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, _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 }
+ 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')
+ })
+ })
+})
diff --git a/frontend/lib/taskStageMapper.test.ts b/frontend/lib/taskStageMapper.test.ts
new file mode 100644
index 0000000..e433136
--- /dev/null
+++ b/frontend/lib/taskStageMapper.test.ts
@@ -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 {
+ 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')
+ })
+})