chore: CI/CD + 前端测试 + 安全加固 + 限流完善
- 新增 .gitlab-ci.yml (lint/test/build 三阶段) - 新增前端测试: taskStageMapper (109), api.ts (36), AuthContext (16) - 修复旧测试: Sidebar 导航文案、MobileLayout padding 值 - python-jose → PyJWT 消除 ecdsa CVE 漏洞 - 限流中间件增加 5 个敏感端点精细限流 + 标准限流头 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3a444864ac
commit
a2f6f82e15
@@ -59,7 +59,7 @@ describe('MobileLayout', () => {
|
||||
</MobileLayout>
|
||||
);
|
||||
const main = container.querySelector('main');
|
||||
expect(main).toHaveClass('pb-[95px]');
|
||||
expect(main).toHaveClass('pb-[80px]');
|
||||
});
|
||||
|
||||
it('showBottomNav=false 时内容区域无底部 padding', () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('Sidebar', () => {
|
||||
it('渲染 agency 导航项', () => {
|
||||
render(<Sidebar role="agency" />);
|
||||
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(<Sidebar role="brand" />);
|
||||
expect(screen.getByText('数据看板')).toBeInTheDocument();
|
||||
expect(screen.getByText('项目看板')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI 配置')).toBeInTheDocument();
|
||||
expect(screen.getByText('规则配置')).toBeInTheDocument();
|
||||
expect(screen.getByText('终审台')).toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* AuthContext Unit Tests
|
||||
*
|
||||
* Tests the AuthProvider, useAuth hook, and the exported USE_MOCK flag.
|
||||
* Covers: initial state, login (mock + real), logout, register, and
|
||||
* restoration of user from localStorage on mount.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use vi.hoisted to create mock functions before vi.mock hoisting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockApiLogin, mockApiRegister, mockClearTokens, mockGetAccessToken } = vi.hoisted(() => ({
|
||||
mockApiLogin: vi.fn(),
|
||||
mockApiRegister: vi.fn(),
|
||||
mockClearTokens: vi.fn(),
|
||||
mockGetAccessToken: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
login: mockApiLogin,
|
||||
register: mockApiRegister,
|
||||
},
|
||||
clearTokens: mockClearTokens,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
setTokens: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import AuthContext after mocks are set up
|
||||
// ---------------------------------------------------------------------------
|
||||
import { AuthProvider, useAuth, USE_MOCK } from './AuthContext'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockLocalStorage() {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(store).forEach((k) => delete store[k])
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AuthContext', () => {
|
||||
let storage: ReturnType<typeof mockLocalStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
storage = mockLocalStorage()
|
||||
Object.defineProperty(window, 'localStorage', { value: storage, writable: true })
|
||||
vi.clearAllMocks()
|
||||
// Default: no stored token
|
||||
mockGetAccessToken.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
storage.clear()
|
||||
})
|
||||
|
||||
// ---- USE_MOCK flag ----
|
||||
|
||||
describe('USE_MOCK export', () => {
|
||||
it('is exported as a boolean', () => {
|
||||
expect(typeof USE_MOCK).toBe('boolean')
|
||||
})
|
||||
|
||||
it('is true in test/development environment or based on env var', () => {
|
||||
// vitest runs with NODE_ENV=test. The AuthContext checks for
|
||||
// NODE_ENV === 'development' or NEXT_PUBLIC_USE_MOCK === 'true'.
|
||||
// We verify the export exists and is a boolean.
|
||||
expect([true, false]).toContain(USE_MOCK)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Initial state ----
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with user as null', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.user).toBeNull()
|
||||
})
|
||||
|
||||
it('starts with isAuthenticated as false', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('starts with isLoading true, then becomes false', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Restoring user from localStorage ----
|
||||
|
||||
describe('restoring user from localStorage', () => {
|
||||
it('restores user when token and stored user exist', async () => {
|
||||
const storedUser = {
|
||||
id: 'u1',
|
||||
name: 'Restored User',
|
||||
email: 'restored@test.com',
|
||||
role: 'brand',
|
||||
is_verified: true,
|
||||
}
|
||||
|
||||
mockGetAccessToken.mockReturnValue('valid-token')
|
||||
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.user).toEqual(storedUser)
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restore user when token is missing', async () => {
|
||||
const storedUser = { id: 'u1', name: 'No Token', role: 'brand', is_verified: true }
|
||||
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
||||
mockGetAccessToken.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.user).toBeNull()
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('clears invalid JSON from localStorage gracefully', async () => {
|
||||
mockGetAccessToken.mockReturnValue('some-token')
|
||||
storage.setItem('miaosi_user', 'not-valid-json{{{')
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockClearTokens).toHaveBeenCalled()
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user')
|
||||
expect(result.current.user).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Login ----
|
||||
|
||||
describe('login', () => {
|
||||
// When USE_MOCK is true, the AuthProvider uses mock login logic.
|
||||
// When false, it calls api.login().
|
||||
|
||||
if (!USE_MOCK) {
|
||||
it('calls api.login and sets user on success (real API path)', async () => {
|
||||
const loginResponse = {
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
token_type: 'bearer',
|
||||
expires_in: 900,
|
||||
user: {
|
||||
id: 'u1',
|
||||
name: 'API User',
|
||||
email: 'api@test.com',
|
||||
role: 'brand',
|
||||
is_verified: true,
|
||||
},
|
||||
}
|
||||
mockApiLogin.mockResolvedValueOnce(loginResponse)
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let loginResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login({
|
||||
email: 'api@test.com',
|
||||
password: 'pass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(loginResult!.success).toBe(true)
|
||||
expect(result.current.user?.name).toBe('API User')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(storage.setItem).toHaveBeenCalledWith(
|
||||
'miaosi_user',
|
||||
expect.stringContaining('API User')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns error on api.login failure (real API path)', async () => {
|
||||
mockApiLogin.mockRejectedValueOnce(new Error('Invalid credentials'))
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let loginResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login({
|
||||
email: 'bad@test.com',
|
||||
password: 'wrong',
|
||||
})
|
||||
})
|
||||
|
||||
expect(loginResult!.success).toBe(false)
|
||||
expect(loginResult!.error).toBe('Invalid credentials')
|
||||
expect(result.current.user).toBeNull()
|
||||
})
|
||||
}
|
||||
|
||||
if (USE_MOCK) {
|
||||
it('logs in with mock user for known email (mock path)', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let loginResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login({
|
||||
email: 'creator@demo.com',
|
||||
password: 'demo123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(loginResult!.success).toBe(true)
|
||||
expect(result.current.user?.email).toBe('creator@demo.com')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('returns error for unknown email in mock mode', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let loginResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login({
|
||||
email: 'unknown@demo.com',
|
||||
password: 'demo123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(loginResult!.success).toBe(false)
|
||||
expect(loginResult!.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns error for wrong password in mock mode', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let loginResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login({
|
||||
email: 'creator@demo.com',
|
||||
password: 'wrongpassword',
|
||||
})
|
||||
})
|
||||
|
||||
expect(loginResult!.success).toBe(false)
|
||||
expect(loginResult!.error).toBe('密码错误')
|
||||
})
|
||||
|
||||
it('allows email code login for known user in mock mode', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let loginResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login({
|
||||
email: 'brand@demo.com',
|
||||
email_code: '123456',
|
||||
})
|
||||
})
|
||||
|
||||
expect(loginResult!.success).toBe(true)
|
||||
expect(result.current.user?.email).toBe('brand@demo.com')
|
||||
expect(result.current.user?.role).toBe('brand')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ---- Register ----
|
||||
|
||||
describe('register', () => {
|
||||
const registerData = {
|
||||
email: 'new@example.com',
|
||||
password: 'secure123',
|
||||
name: 'New User',
|
||||
role: 'creator' as const,
|
||||
email_code: '123456',
|
||||
}
|
||||
|
||||
if (!USE_MOCK) {
|
||||
it('calls api.register and sets user on success (real API path)', async () => {
|
||||
const registerResponse = {
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
token_type: 'bearer',
|
||||
expires_in: 900,
|
||||
user: {
|
||||
id: 'u2',
|
||||
name: 'New User',
|
||||
email: 'new@example.com',
|
||||
role: 'creator',
|
||||
is_verified: false,
|
||||
},
|
||||
}
|
||||
mockApiRegister.mockResolvedValueOnce(registerResponse)
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let registerResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
registerResult = await result.current.register(registerData)
|
||||
})
|
||||
|
||||
expect(registerResult!.success).toBe(true)
|
||||
expect(result.current.user?.name).toBe('New User')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('returns error on api.register failure', async () => {
|
||||
mockApiRegister.mockRejectedValueOnce(new Error('Email already exists'))
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let registerResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
registerResult = await result.current.register(registerData)
|
||||
})
|
||||
|
||||
expect(registerResult!.success).toBe(false)
|
||||
expect(registerResult!.error).toBe('Email already exists')
|
||||
})
|
||||
}
|
||||
|
||||
if (USE_MOCK) {
|
||||
it('creates a mock user on register in mock mode', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
let registerResult: { success: boolean; error?: string }
|
||||
await act(async () => {
|
||||
registerResult = await result.current.register(registerData)
|
||||
})
|
||||
|
||||
expect(registerResult!.success).toBe(true)
|
||||
expect(result.current.user?.email).toBe('new@example.com')
|
||||
expect(result.current.user?.name).toBe('New User')
|
||||
expect(result.current.user?.role).toBe('creator')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ---- Logout ----
|
||||
|
||||
describe('logout', () => {
|
||||
it('clears user state, tokens, and localStorage', async () => {
|
||||
const storedUser = {
|
||||
id: 'u1',
|
||||
name: 'Logged In',
|
||||
email: 'in@test.com',
|
||||
role: 'brand',
|
||||
is_verified: true,
|
||||
}
|
||||
mockGetAccessToken.mockReturnValue('valid-token')
|
||||
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.user).not.toBeNull()
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
})
|
||||
|
||||
expect(result.current.user).toBeNull()
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(mockClearTokens).toHaveBeenCalled()
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- switchRole ----
|
||||
|
||||
describe('switchRole', () => {
|
||||
it('updates the user role and persists to localStorage', async () => {
|
||||
const storedUser = {
|
||||
id: 'u1',
|
||||
name: 'Multi Role',
|
||||
email: 'multi@test.com',
|
||||
role: 'brand',
|
||||
is_verified: true,
|
||||
}
|
||||
mockGetAccessToken.mockReturnValue('valid-token')
|
||||
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.user?.role).toBe('brand')
|
||||
|
||||
act(() => {
|
||||
result.current.switchRole('agency')
|
||||
})
|
||||
|
||||
expect(result.current.user?.role).toBe('agency')
|
||||
// Check localStorage was updated with new role
|
||||
const storedCalls = storage.setItem.mock.calls.filter(
|
||||
([key]: [string, string]) => key === 'miaosi_user'
|
||||
)
|
||||
const lastStored = JSON.parse(storedCalls[storedCalls.length - 1][1])
|
||||
expect(lastStored.role).toBe('agency')
|
||||
})
|
||||
|
||||
it('does nothing when no user is logged in', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.switchRole('creator')
|
||||
})
|
||||
|
||||
expect(result.current.user).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- useAuth outside provider ----
|
||||
|
||||
describe('useAuth outside AuthProvider', () => {
|
||||
it('throws an error when used outside AuthProvider', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useAuth())
|
||||
}).toThrow('useAuth must be used within an AuthProvider')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* API Client Unit Tests
|
||||
*
|
||||
* Tests the ApiClient singleton: axios configuration, token interceptors,
|
||||
* 401 refresh flow, and key API method endpoint mappings.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vi.hoisted() runs BEFORE vi.mock hoisting, so these variables are available
|
||||
// inside the vi.mock factory.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockAxiosInstance,
|
||||
requestInterceptorHolder,
|
||||
responseErrorInterceptorHolder,
|
||||
} = vi.hoisted(() => {
|
||||
const requestInterceptorHolder: { fn: ((config: any) => any) | null } = { fn: null }
|
||||
const responseErrorInterceptorHolder: { fn: ((error: any) => any) | null } = { fn: null }
|
||||
|
||||
const mockAxiosInstance = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
interceptors: {
|
||||
request: {
|
||||
use: vi.fn((onFulfilled: any) => {
|
||||
requestInterceptorHolder.fn = onFulfilled
|
||||
}),
|
||||
},
|
||||
response: {
|
||||
use: vi.fn((_onFulfilled: any, onRejected: any) => {
|
||||
responseErrorInterceptorHolder.fn = onRejected
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return { mockAxiosInstance, requestInterceptorHolder, responseErrorInterceptorHolder }
|
||||
})
|
||||
|
||||
vi.mock('axios', () => {
|
||||
return {
|
||||
default: {
|
||||
create: vi.fn(() => mockAxiosInstance),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import the module under test AFTER mocking axios
|
||||
// ---------------------------------------------------------------------------
|
||||
import axios from 'axios'
|
||||
import { api, getAccessToken, getRefreshToken, setTokens, clearTokens } from './api'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockLocalStorage() {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(store).forEach((k) => delete store[k])
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('API Client', () => {
|
||||
let storage: ReturnType<typeof mockLocalStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
storage = mockLocalStorage()
|
||||
Object.defineProperty(window, 'localStorage', { value: storage, writable: true })
|
||||
|
||||
// Reset all mock call counts but preserve the interceptor captures
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
storage.clear()
|
||||
})
|
||||
|
||||
// ---- Axios instance configuration ----
|
||||
|
||||
describe('axios instance configuration', () => {
|
||||
it('creates axios instance with correct baseURL and timeout', () => {
|
||||
// axios.create is called at module load time before vi.clearAllMocks().
|
||||
// Instead of checking call count, verify the singleton `api` exists and
|
||||
// that axios.create was invoked (the mock instance is connected).
|
||||
// We confirm indirectly: if api.login calls mockAxiosInstance.post,
|
||||
// that proves axios.create returned our mock.
|
||||
expect(api).toBeDefined()
|
||||
// Also verify the interceptor callbacks were captured (proves setup ran)
|
||||
expect(requestInterceptorHolder.fn).not.toBeNull()
|
||||
expect(responseErrorInterceptorHolder.fn).not.toBeNull()
|
||||
})
|
||||
|
||||
it('captures request and response interceptors during setup', () => {
|
||||
// The interceptor functions were captured during module initialization.
|
||||
// Verify they are functions that we can invoke.
|
||||
expect(typeof requestInterceptorHolder.fn).toBe('function')
|
||||
expect(typeof responseErrorInterceptorHolder.fn).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Token management helpers ----
|
||||
|
||||
describe('token management helpers', () => {
|
||||
it('getAccessToken returns null when no token stored', () => {
|
||||
expect(getAccessToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('getAccessToken returns the stored token', () => {
|
||||
storage.setItem('miaosi_access_token', 'test-access-token')
|
||||
expect(getAccessToken()).toBe('test-access-token')
|
||||
})
|
||||
|
||||
it('getRefreshToken returns null when no token stored', () => {
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('getRefreshToken returns the stored refresh token', () => {
|
||||
storage.setItem('miaosi_refresh_token', 'test-refresh-token')
|
||||
expect(getRefreshToken()).toBe('test-refresh-token')
|
||||
})
|
||||
|
||||
it('setTokens stores both access and refresh tokens', () => {
|
||||
setTokens('access-123', 'refresh-456')
|
||||
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'access-123')
|
||||
expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'refresh-456')
|
||||
})
|
||||
|
||||
it('clearTokens removes both tokens from storage', () => {
|
||||
storage.setItem('miaosi_access_token', 'a')
|
||||
storage.setItem('miaosi_refresh_token', 'b')
|
||||
clearTokens()
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Request interceptor ----
|
||||
|
||||
describe('request interceptor', () => {
|
||||
it('adds Authorization header when access token exists', () => {
|
||||
storage.setItem('miaosi_access_token', 'my-token')
|
||||
const config = { headers: {} as Record<string, string> }
|
||||
const result = requestInterceptorHolder.fn!(config)
|
||||
expect(result.headers.Authorization).toBe('Bearer my-token')
|
||||
})
|
||||
|
||||
it('does not add Authorization header when no token', () => {
|
||||
const config = { headers: {} as Record<string, string> }
|
||||
const result = requestInterceptorHolder.fn!(config)
|
||||
expect(result.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('always adds X-Tenant-ID header', () => {
|
||||
const config = { headers: {} as Record<string, string> }
|
||||
const result = requestInterceptorHolder.fn!(config)
|
||||
expect(result.headers['X-Tenant-ID']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Response interceptor: 401 refresh ----
|
||||
|
||||
describe('response interceptor - 401 refresh', () => {
|
||||
it('attempts token refresh on 401 response', async () => {
|
||||
storage.setItem('miaosi_refresh_token', 'valid-refresh')
|
||||
|
||||
const newAccessToken = 'new-access-token-123';
|
||||
|
||||
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: { access_token: newAccessToken, token_type: 'bearer', expires_in: 900 },
|
||||
})
|
||||
|
||||
const error = {
|
||||
response: { status: 401 },
|
||||
config: { headers: {} as Record<string, string>, _retry: false },
|
||||
}
|
||||
|
||||
// The response interceptor will:
|
||||
// 1. Call axios.post to refresh the token
|
||||
// 2. Try to call this.client(originalRequest) to retry
|
||||
// Since mockAxiosInstance is an object (not callable), step 2 throws.
|
||||
// We catch that and verify step 1 happened correctly.
|
||||
try {
|
||||
await responseErrorInterceptorHolder.fn!(error)
|
||||
} catch {
|
||||
// Expected: this.client is not a function (mockAxiosInstance is not callable)
|
||||
}
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/auth/refresh'),
|
||||
{ refresh_token: 'valid-refresh' }
|
||||
)
|
||||
// Verify the new access token was stored
|
||||
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', newAccessToken)
|
||||
})
|
||||
|
||||
it('clears tokens and redirects to /login when refresh fails', async () => {
|
||||
storage.setItem('miaosi_refresh_token', 'expired-refresh')
|
||||
|
||||
;(axios.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Token expired'))
|
||||
|
||||
const originalHref = window.location.href
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const error = {
|
||||
response: { status: 401 },
|
||||
config: { headers: {} as Record<string, string>, _retry: false },
|
||||
}
|
||||
|
||||
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow()
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
|
||||
expect(window.location.href).toBe('/login')
|
||||
|
||||
// Restore
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: originalHref },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with error message for non-401 errors', async () => {
|
||||
const error = {
|
||||
response: { status: 500, data: { detail: 'Internal Server Error' } },
|
||||
config: { headers: {} as Record<string, string> },
|
||||
message: 'Request failed',
|
||||
}
|
||||
|
||||
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow('Internal Server Error')
|
||||
})
|
||||
|
||||
it('falls through to error handler when _retry is already true', async () => {
|
||||
const error = {
|
||||
response: { status: 401 },
|
||||
config: { headers: {} as Record<string, string>, _retry: true },
|
||||
message: 'Unauthorized',
|
||||
}
|
||||
|
||||
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow()
|
||||
expect(axios.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when no refresh token is available', async () => {
|
||||
// No refresh token in storage
|
||||
const error = {
|
||||
response: { status: 401 },
|
||||
config: { headers: {} as Record<string, string>, _retry: false },
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow()
|
||||
// refresh should not have been called since there's no token
|
||||
expect(axios.post).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- API methods: endpoint mapping ----
|
||||
|
||||
describe('api.login()', () => {
|
||||
it('calls POST /auth/login with credentials and stores tokens', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
token_type: 'bearer',
|
||||
expires_in: 900,
|
||||
user: { id: 'u1', name: 'Test', role: 'brand', is_verified: true },
|
||||
},
|
||||
}
|
||||
mockAxiosInstance.post.mockResolvedValueOnce(mockResponse)
|
||||
|
||||
const result = await api.login({ email: 'test@example.com', password: 'pass123' })
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/login', {
|
||||
email: 'test@example.com',
|
||||
password: 'pass123',
|
||||
})
|
||||
expect(result.access_token).toBe('at')
|
||||
expect(result.user.name).toBe('Test')
|
||||
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'at')
|
||||
expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'rt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.register()', () => {
|
||||
it('calls POST /auth/register with user data and stores tokens', async () => {
|
||||
const registerData = {
|
||||
email: 'new@example.com',
|
||||
password: 'secure123',
|
||||
name: 'New User',
|
||||
role: 'creator' as const,
|
||||
email_code: '123456',
|
||||
}
|
||||
const mockResponse = {
|
||||
data: {
|
||||
access_token: 'new-at',
|
||||
refresh_token: 'new-rt',
|
||||
token_type: 'bearer',
|
||||
expires_in: 900,
|
||||
user: { id: 'u2', name: 'New User', email: 'new@example.com', role: 'creator', is_verified: false },
|
||||
},
|
||||
}
|
||||
mockAxiosInstance.post.mockResolvedValueOnce(mockResponse)
|
||||
|
||||
const result = await api.register(registerData)
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/register', registerData)
|
||||
expect(result.user.email).toBe('new@example.com')
|
||||
expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'new-at')
|
||||
expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'new-rt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.listTasks()', () => {
|
||||
it('calls GET /tasks with pagination params', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({
|
||||
data: { items: [], total: 0, page: 1, page_size: 20 },
|
||||
})
|
||||
|
||||
const result = await api.listTasks(1, 20)
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/tasks', {
|
||||
params: { page: 1, page_size: 20, stage: undefined },
|
||||
})
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
|
||||
it('passes stage filter when provided', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({
|
||||
data: { items: [], total: 0, page: 1, page_size: 10 },
|
||||
})
|
||||
|
||||
await api.listTasks(1, 10, 'script_upload' as any)
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/tasks', {
|
||||
params: { page: 1, page_size: 10, stage: 'script_upload' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.getUploadPolicy()', () => {
|
||||
it('calls POST /upload/policy with file type', async () => {
|
||||
mockAxiosInstance.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
x_tos_algorithm: 'algo',
|
||||
x_tos_credential: 'cred',
|
||||
x_tos_date: '2024-01-01',
|
||||
x_tos_signature: 'sig',
|
||||
policy: 'base64policy',
|
||||
host: 'https://oss.example.com',
|
||||
dir: 'uploads/',
|
||||
expire: 3600,
|
||||
max_size_mb: 100,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await api.getUploadPolicy('video')
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/upload/policy', {
|
||||
file_type: 'video',
|
||||
})
|
||||
expect(result.host).toBe('https://oss.example.com')
|
||||
})
|
||||
|
||||
it('defaults to "general" file type when not specified', async () => {
|
||||
mockAxiosInstance.post.mockResolvedValueOnce({ data: {} })
|
||||
|
||||
await api.getUploadPolicy()
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/upload/policy', {
|
||||
file_type: 'general',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.getProfile()', () => {
|
||||
it('calls GET /profile', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'u1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
role: 'brand',
|
||||
is_verified: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await api.getProfile()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/profile')
|
||||
expect(result.name).toBe('Test User')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.getMessages()', () => {
|
||||
it('calls GET /messages with params', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({
|
||||
data: { items: [], total: 0, page: 1, page_size: 20 },
|
||||
})
|
||||
|
||||
const params = { page: 1, page_size: 20, is_read: false }
|
||||
const result = await api.getMessages(params)
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages', { params })
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
|
||||
it('calls GET /messages without params when none provided', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({
|
||||
data: { items: [], total: 0, page: 1, page_size: 20 },
|
||||
})
|
||||
|
||||
await api.getMessages()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages', { params: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.getUnreadCount()', () => {
|
||||
it('calls GET /messages/unread-count', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({ data: { count: 5 } })
|
||||
|
||||
const result = await api.getUnreadCount()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages/unread-count')
|
||||
expect(result.count).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.logout()', () => {
|
||||
it('calls POST /auth/logout and clears tokens', async () => {
|
||||
storage.setItem('miaosi_access_token', 'at')
|
||||
storage.setItem('miaosi_refresh_token', 'rt')
|
||||
mockAxiosInstance.post.mockResolvedValueOnce({ data: undefined })
|
||||
|
||||
await api.logout()
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/logout')
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token')
|
||||
})
|
||||
|
||||
it('clears tokens even when POST /auth/logout fails', async () => {
|
||||
mockAxiosInstance.post.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await api.logout().catch(() => {})
|
||||
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token')
|
||||
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.sendEmailCode()', () => {
|
||||
it('calls POST /auth/send-code', async () => {
|
||||
mockAxiosInstance.post.mockResolvedValueOnce({
|
||||
data: { message: 'Code sent', expires_in: 300 },
|
||||
})
|
||||
|
||||
const result = await api.sendEmailCode({ email: 'a@b.com', purpose: 'login' })
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/send-code', {
|
||||
email: 'a@b.com',
|
||||
purpose: 'login',
|
||||
})
|
||||
expect(result.expires_in).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.resetPassword()', () => {
|
||||
it('calls POST /auth/reset-password', async () => {
|
||||
mockAxiosInstance.post.mockResolvedValueOnce({
|
||||
data: { message: 'Password reset' },
|
||||
})
|
||||
|
||||
const result = await api.resetPassword({
|
||||
email: 'a@b.com',
|
||||
email_code: '123456',
|
||||
new_password: 'newpass',
|
||||
})
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/reset-password', {
|
||||
email: 'a@b.com',
|
||||
email_code: '123456',
|
||||
new_password: 'newpass',
|
||||
})
|
||||
expect(result.message).toBe('Password reset')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.updateProfile()', () => {
|
||||
it('calls PUT /profile with update data', async () => {
|
||||
const updateData = { name: 'Updated Name' }
|
||||
mockAxiosInstance.put.mockResolvedValueOnce({
|
||||
data: { id: 'u1', name: 'Updated Name', role: 'brand', is_verified: true },
|
||||
})
|
||||
|
||||
const result = await api.updateProfile(updateData)
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/profile', updateData)
|
||||
expect(result.name).toBe('Updated Name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.changePassword()', () => {
|
||||
it('calls PUT /profile/password', async () => {
|
||||
mockAxiosInstance.put.mockResolvedValueOnce({
|
||||
data: { message: 'Password changed' },
|
||||
})
|
||||
|
||||
const result = await api.changePassword({
|
||||
old_password: 'old',
|
||||
new_password: 'new',
|
||||
})
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/profile/password', {
|
||||
old_password: 'old',
|
||||
new_password: 'new',
|
||||
})
|
||||
expect(result.message).toBe('Password changed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.markMessageAsRead()', () => {
|
||||
it('calls PUT /messages/:id/read', async () => {
|
||||
mockAxiosInstance.put.mockResolvedValueOnce({ data: undefined })
|
||||
|
||||
await api.markMessageAsRead('msg-001')
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/messages/msg-001/read')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.markAllMessagesAsRead()', () => {
|
||||
it('calls PUT /messages/read-all', async () => {
|
||||
mockAxiosInstance.put.mockResolvedValueOnce({ data: undefined })
|
||||
|
||||
await api.markAllMessagesAsRead()
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/messages/read-all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.setTenantId()', () => {
|
||||
it('updates the tenant ID used in subsequent requests', () => {
|
||||
api.setTenantId('BR002')
|
||||
const config = { headers: {} as Record<string, string> }
|
||||
const result = requestInterceptorHolder.fn!(config)
|
||||
expect(result.headers['X-Tenant-ID']).toBe('BR002')
|
||||
|
||||
// Reset for other tests
|
||||
api.setTenantId('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api.healthCheck()', () => {
|
||||
it('calls GET /health', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({
|
||||
data: { status: 'ok', version: '1.0.0' },
|
||||
})
|
||||
|
||||
const result = await api.healthCheck()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/health')
|
||||
expect(result.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,802 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mapTaskToUI } from './taskStageMapper'
|
||||
import type { TaskResponse, TaskStage, TaskStatus } from '@/types/task'
|
||||
|
||||
/**
|
||||
* Helper: create a minimal TaskResponse with sensible defaults.
|
||||
* Pass overrides for fields you care about.
|
||||
*/
|
||||
function mockTask(overrides: Partial<TaskResponse> = {}): TaskResponse {
|
||||
return {
|
||||
id: 'TK000001',
|
||||
name: '测试任务',
|
||||
sequence: 1,
|
||||
stage: 'script_upload' as TaskStage,
|
||||
project: { id: 'PJ000001', name: '测试项目' },
|
||||
agency: { id: 'AG000001', name: '测试代理商' },
|
||||
creator: { id: 'CR000001', name: '测试达人' },
|
||||
script_file_url: null,
|
||||
script_file_name: null,
|
||||
script_uploaded_at: null,
|
||||
script_ai_score: null,
|
||||
script_ai_result: null,
|
||||
script_agency_status: null,
|
||||
script_agency_comment: null,
|
||||
script_brand_status: null,
|
||||
script_brand_comment: null,
|
||||
video_file_url: null,
|
||||
video_file_name: null,
|
||||
video_duration: null,
|
||||
video_thumbnail_url: null,
|
||||
video_uploaded_at: null,
|
||||
video_ai_score: null,
|
||||
video_ai_result: null,
|
||||
video_agency_status: null,
|
||||
video_agency_comment: null,
|
||||
video_brand_status: null,
|
||||
video_brand_comment: null,
|
||||
appeal_count: 0,
|
||||
is_appeal: false,
|
||||
appeal_reason: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. script_upload stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — script_upload', () => {
|
||||
const task = mockTask({ stage: 'script_upload' })
|
||||
const ui = mapTaskToUI(task)
|
||||
|
||||
it('scriptStage.submit should be "current"', () => {
|
||||
expect(ui.scriptStage.submit).toBe('current')
|
||||
})
|
||||
|
||||
it('remaining script steps should be "pending"', () => {
|
||||
expect(ui.scriptStage.ai).toBe('pending')
|
||||
expect(ui.scriptStage.agency).toBe('pending')
|
||||
expect(ui.scriptStage.brand).toBe('pending')
|
||||
})
|
||||
|
||||
it('all video steps should be "pending"', () => {
|
||||
expect(ui.videoStage.submit).toBe('pending')
|
||||
expect(ui.videoStage.ai).toBe('pending')
|
||||
expect(ui.videoStage.agency).toBe('pending')
|
||||
expect(ui.videoStage.brand).toBe('pending')
|
||||
})
|
||||
|
||||
it('currentPhase should be "script"', () => {
|
||||
expect(ui.currentPhase).toBe('script')
|
||||
})
|
||||
|
||||
it('buttonText should be "上传脚本"', () => {
|
||||
expect(ui.buttonText).toBe('上传脚本')
|
||||
})
|
||||
|
||||
it('buttonType should be "primary"', () => {
|
||||
expect(ui.buttonType).toBe('primary')
|
||||
})
|
||||
|
||||
it('statusLabel should be "待上传"', () => {
|
||||
expect(ui.statusLabel).toBe('待上传')
|
||||
})
|
||||
|
||||
it('filterCategory should be "pending"', () => {
|
||||
expect(ui.filterCategory).toBe('pending')
|
||||
})
|
||||
|
||||
it('scriptColor should be "blue" (no errors, brand not done)', () => {
|
||||
expect(ui.scriptColor).toBe('blue')
|
||||
})
|
||||
|
||||
it('videoColor should be "blue"', () => {
|
||||
expect(ui.videoColor).toBe('blue')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. script_ai_review stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — script_ai_review', () => {
|
||||
it('ai step should be "current" when no result yet', () => {
|
||||
const task = mockTask({ stage: 'script_ai_review', script_ai_result: null })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.submit).toBe('done')
|
||||
expect(ui.scriptStage.ai).toBe('current')
|
||||
expect(ui.scriptStage.agency).toBe('pending')
|
||||
expect(ui.scriptStage.brand).toBe('pending')
|
||||
})
|
||||
|
||||
it('ai step should be "done" when result is present', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_ai_review',
|
||||
script_ai_result: { score: 85, violations: [], soft_warnings: [] },
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.ai).toBe('done')
|
||||
})
|
||||
|
||||
it('statusLabel should be "AI 审核中"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
|
||||
expect(ui.statusLabel).toBe('AI 审核中')
|
||||
})
|
||||
|
||||
it('buttonText should be "审核中" and buttonType "disabled"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
|
||||
expect(ui.buttonText).toBe('审核中')
|
||||
expect(ui.buttonType).toBe('disabled')
|
||||
})
|
||||
|
||||
it('filterCategory should be "reviewing"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
|
||||
expect(ui.filterCategory).toBe('reviewing')
|
||||
})
|
||||
|
||||
it('currentPhase should be "script"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_ai_review' }))
|
||||
expect(ui.currentPhase).toBe('script')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. script_agency_review stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — script_agency_review', () => {
|
||||
it('agency step defaults to "current" when status is null/pending', () => {
|
||||
const task = mockTask({ stage: 'script_agency_review' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.submit).toBe('done')
|
||||
expect(ui.scriptStage.ai).toBe('done')
|
||||
expect(ui.scriptStage.agency).toBe('current')
|
||||
expect(ui.scriptStage.brand).toBe('pending')
|
||||
})
|
||||
|
||||
it('agency step is "current" when status is "processing"', () => {
|
||||
const task = mockTask({ stage: 'script_agency_review', script_agency_status: 'processing' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.agency).toBe('current')
|
||||
})
|
||||
|
||||
it('agency step is "done" when status is "passed"', () => {
|
||||
const task = mockTask({ stage: 'script_agency_review', script_agency_status: 'passed' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.agency).toBe('done')
|
||||
})
|
||||
|
||||
it('agency step is "done" when status is "force_passed"', () => {
|
||||
const task = mockTask({ stage: 'script_agency_review', script_agency_status: 'force_passed' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.agency).toBe('done')
|
||||
})
|
||||
|
||||
it('statusLabel should be "代理商审核中"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_agency_review' }))
|
||||
expect(ui.statusLabel).toBe('代理商审核中')
|
||||
})
|
||||
|
||||
it('buttonText should be "审核中", buttonType "disabled"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_agency_review' }))
|
||||
expect(ui.buttonText).toBe('审核中')
|
||||
expect(ui.buttonType).toBe('disabled')
|
||||
})
|
||||
|
||||
it('filterCategory should be "reviewing"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_agency_review' }))
|
||||
expect(ui.filterCategory).toBe('reviewing')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. script_brand_review stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — script_brand_review', () => {
|
||||
it('brand step defaults to "current" when status is null/pending', () => {
|
||||
const task = mockTask({ stage: 'script_brand_review' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.submit).toBe('done')
|
||||
expect(ui.scriptStage.ai).toBe('done')
|
||||
expect(ui.scriptStage.agency).toBe('done')
|
||||
expect(ui.scriptStage.brand).toBe('current')
|
||||
})
|
||||
|
||||
it('brand step is "current" when status is "processing"', () => {
|
||||
const task = mockTask({ stage: 'script_brand_review', script_brand_status: 'processing' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.brand).toBe('current')
|
||||
})
|
||||
|
||||
it('brand step is "done" when status is "passed"', () => {
|
||||
const task = mockTask({ stage: 'script_brand_review', script_brand_status: 'passed' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.brand).toBe('done')
|
||||
})
|
||||
|
||||
it('statusLabel should be "品牌方审核中"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_brand_review' }))
|
||||
expect(ui.statusLabel).toBe('品牌方审核中')
|
||||
})
|
||||
|
||||
it('buttonText should be "审核中", buttonType "disabled"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_brand_review' }))
|
||||
expect(ui.buttonText).toBe('审核中')
|
||||
expect(ui.buttonType).toBe('disabled')
|
||||
})
|
||||
|
||||
it('filterCategory should be "reviewing"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'script_brand_review' }))
|
||||
expect(ui.filterCategory).toBe('reviewing')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. video_upload stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — video_upload', () => {
|
||||
const task = mockTask({ stage: 'video_upload' })
|
||||
const ui = mapTaskToUI(task)
|
||||
|
||||
it('all script steps should be "done"', () => {
|
||||
expect(ui.scriptStage.submit).toBe('done')
|
||||
expect(ui.scriptStage.ai).toBe('done')
|
||||
expect(ui.scriptStage.agency).toBe('done')
|
||||
expect(ui.scriptStage.brand).toBe('done')
|
||||
})
|
||||
|
||||
it('videoStage.submit should be "current"', () => {
|
||||
expect(ui.videoStage.submit).toBe('current')
|
||||
})
|
||||
|
||||
it('remaining video steps should be "pending"', () => {
|
||||
expect(ui.videoStage.ai).toBe('pending')
|
||||
expect(ui.videoStage.agency).toBe('pending')
|
||||
expect(ui.videoStage.brand).toBe('pending')
|
||||
})
|
||||
|
||||
it('currentPhase should be "video"', () => {
|
||||
expect(ui.currentPhase).toBe('video')
|
||||
})
|
||||
|
||||
it('buttonText should be "上传视频"', () => {
|
||||
expect(ui.buttonText).toBe('上传视频')
|
||||
})
|
||||
|
||||
it('buttonType should be "primary"', () => {
|
||||
expect(ui.buttonType).toBe('primary')
|
||||
})
|
||||
|
||||
it('statusLabel should be "待上传"', () => {
|
||||
expect(ui.statusLabel).toBe('待上传')
|
||||
})
|
||||
|
||||
it('filterCategory should be "pending"', () => {
|
||||
expect(ui.filterCategory).toBe('pending')
|
||||
})
|
||||
|
||||
it('scriptColor should be "green" (brand done)', () => {
|
||||
expect(ui.scriptColor).toBe('green')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. video_ai_review stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — video_ai_review', () => {
|
||||
it('video ai step should be "current" when no result', () => {
|
||||
const task = mockTask({ stage: 'video_ai_review', video_ai_result: null })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.submit).toBe('done')
|
||||
expect(ui.videoStage.ai).toBe('current')
|
||||
expect(ui.videoStage.agency).toBe('pending')
|
||||
expect(ui.videoStage.brand).toBe('pending')
|
||||
})
|
||||
|
||||
it('video ai step should be "done" when result is present', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_ai_review',
|
||||
video_ai_result: { score: 90, violations: [], soft_warnings: [] },
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.ai).toBe('done')
|
||||
})
|
||||
|
||||
it('currentPhase should be "video"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
|
||||
expect(ui.currentPhase).toBe('video')
|
||||
})
|
||||
|
||||
it('all script steps should be "done"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
|
||||
expect(ui.scriptStage.submit).toBe('done')
|
||||
expect(ui.scriptStage.ai).toBe('done')
|
||||
expect(ui.scriptStage.agency).toBe('done')
|
||||
expect(ui.scriptStage.brand).toBe('done')
|
||||
})
|
||||
|
||||
it('statusLabel should be "AI 审核中"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
|
||||
expect(ui.statusLabel).toBe('AI 审核中')
|
||||
})
|
||||
|
||||
it('buttonText should be "审核中", buttonType "disabled"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
|
||||
expect(ui.buttonText).toBe('审核中')
|
||||
expect(ui.buttonType).toBe('disabled')
|
||||
})
|
||||
|
||||
it('filterCategory should be "reviewing"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_ai_review' }))
|
||||
expect(ui.filterCategory).toBe('reviewing')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. video_agency_review stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — video_agency_review', () => {
|
||||
it('video agency step defaults to "current" when status is null', () => {
|
||||
const task = mockTask({ stage: 'video_agency_review' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.submit).toBe('done')
|
||||
expect(ui.videoStage.ai).toBe('done')
|
||||
expect(ui.videoStage.agency).toBe('current')
|
||||
expect(ui.videoStage.brand).toBe('pending')
|
||||
})
|
||||
|
||||
it('video agency step is "current" when status is "processing"', () => {
|
||||
const task = mockTask({ stage: 'video_agency_review', video_agency_status: 'processing' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.agency).toBe('current')
|
||||
})
|
||||
|
||||
it('video agency step is "done" when status is "passed"', () => {
|
||||
const task = mockTask({ stage: 'video_agency_review', video_agency_status: 'passed' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.agency).toBe('done')
|
||||
})
|
||||
|
||||
it('currentPhase should be "video"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
|
||||
expect(ui.currentPhase).toBe('video')
|
||||
})
|
||||
|
||||
it('statusLabel should be "代理商审核中"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
|
||||
expect(ui.statusLabel).toBe('代理商审核中')
|
||||
})
|
||||
|
||||
it('buttonText should be "审核中", buttonType "disabled"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
|
||||
expect(ui.buttonText).toBe('审核中')
|
||||
expect(ui.buttonType).toBe('disabled')
|
||||
})
|
||||
|
||||
it('filterCategory should be "reviewing"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_agency_review' }))
|
||||
expect(ui.filterCategory).toBe('reviewing')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. video_brand_review stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — video_brand_review', () => {
|
||||
it('video brand step defaults to "current" when status is null', () => {
|
||||
const task = mockTask({ stage: 'video_brand_review' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.submit).toBe('done')
|
||||
expect(ui.videoStage.ai).toBe('done')
|
||||
expect(ui.videoStage.agency).toBe('done')
|
||||
expect(ui.videoStage.brand).toBe('current')
|
||||
})
|
||||
|
||||
it('video brand step is "current" when status is "processing"', () => {
|
||||
const task = mockTask({ stage: 'video_brand_review', video_brand_status: 'processing' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.brand).toBe('current')
|
||||
})
|
||||
|
||||
it('video brand step is "done" when status is "passed"', () => {
|
||||
const task = mockTask({ stage: 'video_brand_review', video_brand_status: 'passed' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.brand).toBe('done')
|
||||
})
|
||||
|
||||
it('currentPhase should be "video"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_brand_review' }))
|
||||
expect(ui.currentPhase).toBe('video')
|
||||
})
|
||||
|
||||
it('statusLabel should be "品牌方审核中"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_brand_review' }))
|
||||
expect(ui.statusLabel).toBe('品牌方审核中')
|
||||
})
|
||||
|
||||
it('filterCategory should be "reviewing"', () => {
|
||||
const ui = mapTaskToUI(mockTask({ stage: 'video_brand_review' }))
|
||||
expect(ui.filterCategory).toBe('reviewing')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. completed stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — completed', () => {
|
||||
const task = mockTask({ stage: 'completed' })
|
||||
const ui = mapTaskToUI(task)
|
||||
|
||||
it('all script steps should be "done"', () => {
|
||||
expect(ui.scriptStage.submit).toBe('done')
|
||||
expect(ui.scriptStage.ai).toBe('done')
|
||||
expect(ui.scriptStage.agency).toBe('done')
|
||||
expect(ui.scriptStage.brand).toBe('done')
|
||||
})
|
||||
|
||||
it('all video steps should be "done"', () => {
|
||||
expect(ui.videoStage.submit).toBe('done')
|
||||
expect(ui.videoStage.ai).toBe('done')
|
||||
expect(ui.videoStage.agency).toBe('done')
|
||||
expect(ui.videoStage.brand).toBe('done')
|
||||
})
|
||||
|
||||
it('currentPhase should be "completed"', () => {
|
||||
expect(ui.currentPhase).toBe('completed')
|
||||
})
|
||||
|
||||
it('buttonText should be "已完成"', () => {
|
||||
expect(ui.buttonText).toBe('已完成')
|
||||
})
|
||||
|
||||
it('buttonType should be "success"', () => {
|
||||
expect(ui.buttonType).toBe('success')
|
||||
})
|
||||
|
||||
it('statusLabel should be "已完成"', () => {
|
||||
expect(ui.statusLabel).toBe('已完成')
|
||||
})
|
||||
|
||||
it('filterCategory should be "completed"', () => {
|
||||
expect(ui.filterCategory).toBe('completed')
|
||||
})
|
||||
|
||||
it('scriptColor should be "green"', () => {
|
||||
expect(ui.scriptColor).toBe('green')
|
||||
})
|
||||
|
||||
it('videoColor should be "green"', () => {
|
||||
expect(ui.videoColor).toBe('green')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. rejected stage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — rejected', () => {
|
||||
const task = mockTask({ stage: 'rejected' })
|
||||
const ui = mapTaskToUI(task)
|
||||
|
||||
it('buttonText should be "重新提交"', () => {
|
||||
expect(ui.buttonText).toBe('重新提交')
|
||||
})
|
||||
|
||||
it('buttonType should be "warning"', () => {
|
||||
expect(ui.buttonType).toBe('warning')
|
||||
})
|
||||
|
||||
it('statusLabel should be "已驳回"', () => {
|
||||
expect(ui.statusLabel).toBe('已驳回')
|
||||
})
|
||||
|
||||
it('filterCategory should be "rejected"', () => {
|
||||
expect(ui.filterCategory).toBe('rejected')
|
||||
})
|
||||
|
||||
it('all script steps should be "done" (stageIndex >= 4)', () => {
|
||||
expect(ui.scriptStage.submit).toBe('done')
|
||||
expect(ui.scriptStage.ai).toBe('done')
|
||||
expect(ui.scriptStage.agency).toBe('done')
|
||||
expect(ui.scriptStage.brand).toBe('done')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. Script agency rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — script agency rejected', () => {
|
||||
it('scriptStage.agency should be "error" even during script_agency_review', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_agency_review',
|
||||
script_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.agency).toBe('error')
|
||||
})
|
||||
|
||||
it('scriptColor should be "red" when agency is error', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_agency_review',
|
||||
script_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptColor).toBe('red')
|
||||
})
|
||||
|
||||
it('filterCategory should be "rejected" when agency rejected', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_agency_review',
|
||||
script_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe('rejected')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Script brand rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — script brand rejected', () => {
|
||||
it('scriptStage.brand should be "error"', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_brand_review',
|
||||
script_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.brand).toBe('error')
|
||||
})
|
||||
|
||||
it('scriptColor should be "red" when brand is error', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_brand_review',
|
||||
script_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptColor).toBe('red')
|
||||
})
|
||||
|
||||
it('filterCategory should be "rejected"', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_brand_review',
|
||||
script_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe('rejected')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. Video agency rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — video agency rejected', () => {
|
||||
it('videoStage.agency should be "error"', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_agency_review',
|
||||
video_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.agency).toBe('error')
|
||||
})
|
||||
|
||||
it('videoColor should be "red" when video agency is error', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_agency_review',
|
||||
video_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoColor).toBe('red')
|
||||
})
|
||||
|
||||
it('filterCategory should be "rejected"', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_agency_review',
|
||||
video_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe('rejected')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13b. Video brand rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — video brand rejected', () => {
|
||||
it('videoStage.brand should be "error"', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_brand_review',
|
||||
video_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.brand).toBe('error')
|
||||
})
|
||||
|
||||
it('videoColor should be "red"', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_brand_review',
|
||||
video_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoColor).toBe('red')
|
||||
})
|
||||
|
||||
it('filterCategory should be "rejected"', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_brand_review',
|
||||
video_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe('rejected')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. filterCategory across different stages
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — filterCategory for different stages', () => {
|
||||
const cases: Array<{ stage: TaskStage; expected: string; desc: string }> = [
|
||||
{ stage: 'script_upload', expected: 'pending', desc: 'script_upload => pending' },
|
||||
{ stage: 'video_upload', expected: 'pending', desc: 'video_upload => pending' },
|
||||
{ stage: 'script_ai_review', expected: 'reviewing', desc: 'script_ai_review => reviewing' },
|
||||
{ stage: 'script_agency_review', expected: 'reviewing', desc: 'script_agency_review => reviewing' },
|
||||
{ stage: 'script_brand_review', expected: 'reviewing', desc: 'script_brand_review => reviewing' },
|
||||
{ stage: 'video_ai_review', expected: 'reviewing', desc: 'video_ai_review => reviewing' },
|
||||
{ stage: 'video_agency_review', expected: 'reviewing', desc: 'video_agency_review => reviewing' },
|
||||
{ stage: 'video_brand_review', expected: 'reviewing', desc: 'video_brand_review => reviewing' },
|
||||
{ stage: 'completed', expected: 'completed', desc: 'completed => completed' },
|
||||
{ stage: 'rejected', expected: 'rejected', desc: 'rejected => rejected' },
|
||||
]
|
||||
|
||||
cases.forEach(({ stage, expected, desc }) => {
|
||||
it(desc, () => {
|
||||
const task = mockTask({ stage })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15. Rejection override: any rejected status forces filterCategory to rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — rejection status overrides filterCategory', () => {
|
||||
it('video_brand_review with script_agency_status=rejected => filterCategory=rejected', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_brand_review',
|
||||
script_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe('rejected')
|
||||
})
|
||||
|
||||
it('video_ai_review with video_agency_status=rejected => filterCategory=rejected', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_ai_review',
|
||||
video_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe('rejected')
|
||||
})
|
||||
|
||||
it('completed stage ignores past rejections (filterCategory stays completed)', () => {
|
||||
// The source code: if (stage !== 'completed') filterCategory = 'rejected'
|
||||
const task = mockTask({
|
||||
stage: 'completed',
|
||||
script_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.filterCategory).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 16. statusToStep — internal mapping via statusToStep through mapTaskToUI
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — statusToStep mapping via agency/brand statuses', () => {
|
||||
it('force_passed maps to done', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_brand_review',
|
||||
script_brand_status: 'force_passed',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.brand).toBe('done')
|
||||
})
|
||||
|
||||
it('processing maps to current', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_brand_review',
|
||||
video_brand_status: 'processing',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.brand).toBe('current')
|
||||
})
|
||||
|
||||
it('null status defaults to current (pending fallback to current)', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_agency_review',
|
||||
video_agency_status: null,
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.agency).toBe('current')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 17. Color logic
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — color logic', () => {
|
||||
it('scriptColor is "green" when script brand is done and no errors', () => {
|
||||
const task = mockTask({ stage: 'video_upload' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptColor).toBe('green')
|
||||
})
|
||||
|
||||
it('scriptColor is "blue" when script brand is not yet done and no errors', () => {
|
||||
const task = mockTask({ stage: 'script_ai_review' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptColor).toBe('blue')
|
||||
})
|
||||
|
||||
it('scriptColor is "red" when script agency has error', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_agency_review',
|
||||
script_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptColor).toBe('red')
|
||||
})
|
||||
|
||||
it('scriptColor is "red" when script brand has error', () => {
|
||||
const task = mockTask({
|
||||
stage: 'script_brand_review',
|
||||
script_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptColor).toBe('red')
|
||||
})
|
||||
|
||||
it('videoColor is "green" when completed', () => {
|
||||
const task = mockTask({ stage: 'completed' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoColor).toBe('green')
|
||||
})
|
||||
|
||||
it('videoColor is "blue" when video is in progress with no errors', () => {
|
||||
const task = mockTask({ stage: 'video_ai_review' })
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoColor).toBe('blue')
|
||||
})
|
||||
|
||||
it('videoColor is "red" when video brand has error', () => {
|
||||
const task = mockTask({
|
||||
stage: 'video_brand_review',
|
||||
video_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoColor).toBe('red')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 18. Edge case: rejected stage with various rejection sources
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('mapTaskToUI — rejected stage with rejection source details', () => {
|
||||
it('rejected stage with script_brand_status=rejected shows error on script brand', () => {
|
||||
const task = mockTask({
|
||||
stage: 'rejected',
|
||||
script_brand_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.scriptStage.brand).toBe('error')
|
||||
expect(ui.scriptColor).toBe('red')
|
||||
})
|
||||
|
||||
it('rejected stage with video_agency_status=rejected shows error on video agency', () => {
|
||||
const task = mockTask({
|
||||
stage: 'rejected',
|
||||
video_agency_status: 'rejected',
|
||||
})
|
||||
const ui = mapTaskToUI(task)
|
||||
expect(ui.videoStage.agency).toBe('error')
|
||||
expect(ui.videoColor).toBe('red')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user