Files
admin-web/src/shared/api/http.test.ts
T
2026-08-12 14:08:33 +08:00

167 lines
6.1 KiB
TypeScript

import axios, { AxiosHeaders } from 'axios'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApiResponseError,
getApiErrorDetails,
http,
registerAuthRefreshHandler,
registerAuthSessionExpiredHandler,
unwrapApiResponse,
} from './http'
const originalAdapter = http.defaults.adapter
afterEach(() => {
http.defaults.adapter = originalAdapter
registerAuthRefreshHandler(async () => { throw new Error('refresh handler is not configured') })
registerAuthSessionExpiredHandler(() => undefined)
})
describe('API error diagnostics', () => {
it('treats only body code 200 as success', () => {
expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } }))
.toEqual({ id: '1' })
expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null }))
.toThrow(ApiResponseError)
})
it('keeps business request and trace identifiers when unwrapping fails', () => {
expect(() => unwrapApiResponse({
code: 'A0403',
message: '无权限',
data: null,
requestId: 'request-1',
traceId: 'trace-1',
})).toThrow(ApiResponseError)
try {
unwrapApiResponse({ code: 'A0403', message: '无权限', data: null, requestId: 'request-1', traceId: 'trace-1' })
} catch (error) {
expect(getApiErrorDetails(error)).toEqual({
message: '当前账号无权访问该数据,请联系管理员确认权限',
code: 'A0403',
requestId: 'request-1',
traceId: 'trace-1',
})
}
})
it('extracts identifiers from an HTTP error envelope', () => {
const error = new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
status: 503,
statusText: 'Service Unavailable',
headers: {},
config: { headers: new AxiosHeaders() },
data: { code: 'S0503', message: '服务不可用', requestId: 'request-2', traceId: 'trace-2' },
})
expect(getApiErrorDetails(error)).toEqual({
message: '服务暂时不可用,请稍后重试',
code: 'S0503',
requestId: 'request-2',
traceId: 'trace-2',
})
})
it('uses safe actionable messages for HTTP authentication and permission failures', () => {
const createError = (status: number, data: unknown) => new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
status,
statusText: 'Request failed',
headers: {},
config: { headers: new AxiosHeaders() },
data,
})
expect(getApiErrorDetails(createError(401, { message: 'token parse stack' })).message)
.toBe('登录状态已失效,请重新登录')
expect(getApiErrorDetails(createError(403, { message: 'policy engine details' })).message)
.toBe('当前账号无权访问该数据,请联系管理员确认权限')
expect(getApiErrorDetails(createError(502, '<html>upstream failed</html>')).message)
.toBe('服务暂时不可用,请稍后重试')
})
it('explains a proxy 413 response without a JSON error envelope', () => {
const error = new axios.AxiosError('Request failed', 'ERR_BAD_REQUEST', undefined, undefined, {
status: 413,
statusText: 'Payload Too Large',
headers: {},
config: { headers: new AxiosHeaders() },
data: '<html>Request Entity Too Large</html>',
})
expect(getApiErrorDetails(error)).toEqual({
message: '上传文件的请求体过大,服务器已拒绝;请压缩后重试,如文件未超过 5MB,请联系管理员检查网关上传限制',
code: 'HTTP_413',
requestId: undefined,
traceId: undefined,
})
})
it('does not expose local exception text in user-facing diagnostics', () => {
expect(getApiErrorDetails(new Error('Error: socket failed\\n at internal/request.ts:42'))).toEqual({
message: '接口请求失败', code: undefined, requestId: undefined, traceId: undefined,
})
})
it('notifies session expiry once when concurrent 401 requests share a failed refresh', async () => {
const refresh = vi.fn(async () => { throw new Error('refresh token expired') })
const expired = vi.fn()
registerAuthRefreshHandler(refresh)
registerAuthSessionExpiredHandler(expired)
http.defaults.adapter = async (config) => {
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
status: 401,
statusText: 'Unauthorized',
headers: {},
config,
data: { code: 'A0401', message: '登录状态已过期' },
})
}
const results = await Promise.allSettled([
http.get('/platform/v1/orders'),
http.get('/platform/v1/warehouses'),
])
expect(results.every((result) => result.status === 'rejected')).toBe(true)
expect(refresh).toHaveBeenCalledTimes(1)
expect(expired).toHaveBeenCalledTimes(1)
})
it('refreshes once and replays the original request with the rotated access token', async () => {
const refresh = vi.fn(async () => 'access-rotated')
const expired = vi.fn()
let orderRequests = 0
let replayAuthorization: unknown
registerAuthRefreshHandler(refresh)
registerAuthSessionExpiredHandler(expired)
http.defaults.adapter = async (config) => {
if (config.url === '/platform/v1/orders' && orderRequests++ === 0) {
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
status: 401,
statusText: 'Unauthorized',
headers: {},
config,
data: { code: 'A0401', message: '登录状态已过期' },
})
}
replayAuthorization = config.headers.Authorization
return {
status: 200,
statusText: 'OK',
headers: {},
config,
data: { code: '200', message: 'success', data: [{ id: 'order-1' }] },
}
}
const response = await http.get('/platform/v1/orders')
expect(refresh).toHaveBeenCalledTimes(1)
expect(expired).not.toHaveBeenCalled()
expect(orderRequests).toBe(2)
expect(replayAuthorization).toBe('Bearer access-rotated')
expect(response).toEqual({ code: '200', message: 'success', data: [{ id: 'order-1' }] })
})
})