- 新增 .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>
600 lines
20 KiB
TypeScript
600 lines
20 KiB
TypeScript
/**
|
|
* 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')
|
|
})
|
|
})
|
|
})
|