Files
sub2api/frontend/src/api/__tests__/client.spec.ts
2026-02-10 00:37:56 +08:00

209 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios'
import type { AxiosInstance } from 'axios'
// 需要在导入 client 之前设置 mock
vi.mock('@/i18n', () => ({
getLocale: () => 'zh-CN',
}))
describe('API Client', () => {
let apiClient: AxiosInstance
beforeEach(async () => {
localStorage.clear()
// 每次测试重新导入以获取干净的模块状态
vi.resetModules()
const mod = await import('@/api/client')
apiClient = mod.apiClient
})
afterEach(() => {
vi.restoreAllMocks()
})
// --- 请求拦截器 ---
describe('请求拦截器', () => {
it('自动附加 Authorization 头', async () => {
localStorage.setItem('auth_token', 'my-jwt-token')
// 拦截实际请求
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('Authorization')).toBe('Bearer my-jwt-token')
})
it('无 token 时不附加 Authorization 头', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('Authorization')).toBeFalsy()
})
it('GET 请求自动附加 timezone 参数', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.params).toHaveProperty('timezone')
})
it('POST 请求不附加 timezone 参数', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.post('/test', { foo: 'bar' })
const config = adapter.mock.calls[0][0]
expect(config.params?.timezone).toBeUndefined()
})
})
// --- 响应拦截器 ---
describe('响应拦截器', () => {
it('code=0 时解包 data 字段', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: { name: 'test' }, message: 'ok' },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
const response = await apiClient.get('/test')
expect(response.data).toEqual({ name: 'test' })
})
it('code!=0 时拒绝并返回结构化错误', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 1001, message: '参数错误', data: null },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toEqual(
expect.objectContaining({
code: 1001,
message: '参数错误',
})
)
})
})
// --- 401 Token 刷新 ---
describe('401 Token 刷新', () => {
it('无 refresh_token 时 401 清除 localStorage', async () => {
localStorage.setItem('auth_token', 'expired-token')
// 不设置 refresh_token
// Mock window.location
const originalLocation = window.location
Object.defineProperty(window, 'location', {
value: { ...originalLocation, pathname: '/dashboard', href: '/dashboard' },
writable: true,
})
const adapter = vi.fn().mockRejectedValue({
response: {
status: 401,
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
},
config: {
url: '/test',
headers: { Authorization: 'Bearer expired-token' },
},
code: 'ERR_BAD_REQUEST',
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toBeDefined()
expect(localStorage.getItem('auth_token')).toBeNull()
// 恢复 location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
})
})
})
// --- 网络错误 ---
describe('网络错误', () => {
it('网络错误返回 status 0 的错误', async () => {
const adapter = vi.fn().mockRejectedValue({
code: 'ERR_NETWORK',
message: 'Network Error',
config: { url: '/test' },
// 没有 response
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toEqual(
expect.objectContaining({
status: 0,
message: 'Network error. Please check your connection.',
})
)
})
})
// --- 请求取消 ---
describe('请求取消', () => {
it('取消的请求保持原始取消错误', async () => {
const source = axios.CancelToken.source()
const adapter = vi.fn().mockRejectedValue(
new axios.Cancel('Operation canceled')
)
apiClient.defaults.adapter = adapter
await expect(
apiClient.get('/test', { cancelToken: source.token })
).rejects.toBeDefined()
})
})
})