frontend layout and infrastructure
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { api } from '../request'
|
||||
import { ApiError } from '@/types/api'
|
||||
|
||||
describe('request utils', () => {
|
||||
const mockFetch = vi.fn()
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mockFetch
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
function mockResponse(body: Record<string, unknown>, status = 200) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('token auto-injection', () => {
|
||||
it('sends Authorization header when token exists', async () => {
|
||||
localStorage.setItem('access_token', 'test-token')
|
||||
mockResponse({ code: 0, data: { id: 1 }, message: 'ok' })
|
||||
|
||||
await api.get('/api/test')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]!
|
||||
expect(options.headers['Authorization']).toBe('Bearer test-token')
|
||||
})
|
||||
|
||||
it('omits Authorization header when no token', async () => {
|
||||
mockResponse({ code: 0, data: null, message: 'ok' })
|
||||
|
||||
await api.get('/api/test')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]!
|
||||
expect(options.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('response parsing', () => {
|
||||
it('returns data field on success (code 0)', async () => {
|
||||
mockResponse({ code: 0, data: { users: [] }, message: 'ok' })
|
||||
|
||||
const result = await api.get('/api/users')
|
||||
|
||||
expect(result).toEqual({ users: [] })
|
||||
})
|
||||
|
||||
it('throws ApiError on business error (code !== 0)', async () => {
|
||||
mockResponse({ code: 1001, data: null, message: '参数错误' })
|
||||
|
||||
await expect(api.get('/api/test')).rejects.toThrow(ApiError)
|
||||
await mockResponse({ code: 1001, data: null, message: '参数错误' })
|
||||
|
||||
try {
|
||||
await api.get('/api/test')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ApiError)
|
||||
expect((e as ApiError).code).toBe(1001)
|
||||
expect((e as ApiError).message).toBe('参数错误')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('401 handling', () => {
|
||||
it('clears tokens and redirects on 401', async () => {
|
||||
localStorage.setItem('access_token', 'expired-token')
|
||||
localStorage.setItem('refresh_token', 'expired-refresh')
|
||||
localStorage.setItem('user', '{"id":1}')
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ code: 401, message: 'Unauthorized' }),
|
||||
})
|
||||
|
||||
await expect(api.get('/api/test')).rejects.toThrow('登录已过期')
|
||||
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
expect(localStorage.getItem('user')).toBeNull()
|
||||
expect(window.location.href).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTTP methods', () => {
|
||||
it('GET with params', async () => {
|
||||
mockResponse({ code: 0, data: [], message: 'ok' })
|
||||
|
||||
await api.get('/api/users', { page: 1, per_page: 15, name: '' })
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0]!
|
||||
expect(url).toContain('/api/users?')
|
||||
expect(url).toContain('page=1')
|
||||
expect(url).toContain('per_page=15')
|
||||
expect(url).not.toContain('name=')
|
||||
expect(options.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('POST with body', async () => {
|
||||
mockResponse({ code: 0, data: { id: 1 }, message: 'ok' })
|
||||
|
||||
await api.post('/api/users', { username: 'test' })
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]!
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.body).toBe(JSON.stringify({ username: 'test' }))
|
||||
})
|
||||
|
||||
it('PUT with body', async () => {
|
||||
mockResponse({ code: 0, data: null, message: 'ok' })
|
||||
|
||||
await api.put('/api/users/1', { username: 'updated' })
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]!
|
||||
expect(options.method).toBe('PUT')
|
||||
})
|
||||
|
||||
it('PATCH with body', async () => {
|
||||
mockResponse({ code: 0, data: null, message: 'ok' })
|
||||
|
||||
await api.patch('/api/users/1', { status: 1 })
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]!
|
||||
expect(options.method).toBe('PATCH')
|
||||
})
|
||||
|
||||
it('DELETE', async () => {
|
||||
mockResponse({ code: 0, data: null, message: 'ok' })
|
||||
|
||||
await api.delete('/api/users/1')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]!
|
||||
expect(options.method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('params serialization', () => {
|
||||
it('filters out undefined, null, and empty string values', async () => {
|
||||
mockResponse({ code: 0, data: [], message: 'ok' })
|
||||
|
||||
await api.get('/api/test', {
|
||||
a: 'hello',
|
||||
b: undefined,
|
||||
c: null,
|
||||
d: '',
|
||||
e: 0,
|
||||
})
|
||||
|
||||
const [url] = mockFetch.mock.calls[0]!
|
||||
expect(url).toContain('a=hello')
|
||||
expect(url).toContain('e=0')
|
||||
expect(url).not.toContain('b=')
|
||||
expect(url).not.toContain('c=')
|
||||
expect(url).not.toContain('d=')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApiError } from '@/types/api'
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function request<T = unknown>(url: string, options: RequestOptions = {}): Promise<T> {
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 拼接查询参数
|
||||
if (options.params) {
|
||||
const query = new URLSearchParams(
|
||||
Object.entries(options.params)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
)
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
url = `${url}${separator}${query}`
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { params: _params, ...fetchOptions } = options
|
||||
|
||||
const response = await fetch(url, { ...fetchOptions, headers })
|
||||
|
||||
// 401 未授权
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
throw new ApiError('登录已过期,请重新登录', 401)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// 业务错误
|
||||
if (result.code !== 0) {
|
||||
throw new ApiError(result.message || '请求失败', result.code)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, params?: Record<string, unknown>) =>
|
||||
request<T>(url, { method: 'GET', params }),
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown) =>
|
||||
request<T>(url, { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
put: <T = unknown>(url: string, data?: unknown) =>
|
||||
request<T>(url, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown) =>
|
||||
request<T>(url, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
|
||||
delete: <T = unknown>(url: string) =>
|
||||
request<T>(url, { method: 'DELETE' }),
|
||||
}
|
||||
Reference in New Issue
Block a user