From a2f6f82e1548c60dee2b03600d322498570931a1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 10 Feb 2026 11:25:29 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20CI/CD=20+=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20+=20=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=20+=20=E9=99=90=E6=B5=81=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 .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 --- .gitlab-ci.yml | 94 ++ backend/app/middleware/rate_limit.py | 95 ++- backend/app/services/auth.py | 3 +- backend/pyproject.toml | 3 +- .../components/layout/MobileLayout.test.tsx | 2 +- .../components/navigation/Sidebar.test.tsx | 4 +- frontend/contexts/AuthContext.test.tsx | 520 ++++++++++++ frontend/lib/api.test.ts | 599 +++++++++++++ frontend/lib/taskStageMapper.test.ts | 802 ++++++++++++++++++ 9 files changed, 2100 insertions(+), 22 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 frontend/contexts/AuthContext.test.tsx create mode 100644 frontend/lib/api.test.ts create mode 100644 frontend/lib/taskStageMapper.test.ts 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') + }) +})