Files
user-system/frontend/admin/src/lib/http/client.test.ts
Your Name 320aa9476f fix(frontend): ApiResponse data nullability contract
- Change ApiResponse.data from T to T | null to match backend reality
- Add compile-time type contract file (http.typecheck.ts)
- Maintain backward compatibility with existing service calls
- Add test for success response with null data

Refs: review-fix-closure-2026-05-28 ApiResponse nullability
2026-05-29 12:32:09 +08:00

802 lines
22 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type JsonResponseInit = ResponseInit & {
status?: number
}
function jsonResponse(data: unknown, init: JsonResponseInit = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
...init,
})
}
async function loadModules() {
vi.resetModules()
const session = await import('@/lib/http/auth-session')
const storage = await import('@/lib/storage')
const csrf = await import('@/lib/http/csrf')
const errors = await import('@/lib/errors')
const client = await import('@/lib/http/client')
return {
...session,
...storage,
...csrf,
...errors,
...client,
}
}
describe('http client', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
it('builds query-string urls and skips undefined params without auth headers', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
const result = await get(
'/users',
{ page: 2, active: true, keyword: undefined },
{ auth: false },
)
expect(result).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [requestUrl, requestInit] = fetchMock.mock.calls[0]
expect(String(requestUrl)).toBe(`${window.location.origin}/api/v1/users?page=2&active=true`)
expect(requestInit?.headers).not.toMatchObject({
Authorization: expect.any(String),
})
})
it('supports relative api base urls without a leading slash', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'api/custom')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
await get('/status', undefined, { auth: false })
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/custom/status`,
expect.any(Object),
)
})
it('supports absolute api base urls', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
await get('/status', undefined, { auth: false })
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/base/status',
expect.any(Object),
)
})
it('sends FormData without forcing a JSON content type', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { uploaded: true },
}),
)
const { post } = await loadModules()
const formData = new FormData()
formData.append('file', new Blob(['demo'], { type: 'text/plain' }), 'demo.txt')
const result = await post('/upload', formData, { auth: false })
expect(result).toEqual({ uploaded: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [requestUrl, requestInit] = fetchMock.mock.calls[0]
const headers = requestInit?.headers as Record<string, string> | undefined
expect(String(requestUrl)).toContain('/api/v1/upload')
expect(requestInit?.body).toBe(formData)
expect(requestInit?.credentials).toBe('include')
expect(headers?.['Content-Type']).toBeUndefined()
})
it('adds csrf and json headers for protected write requests', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { saved: true },
}),
)
const { CSRF_HEADER_NAME, put, setCSRFToken } = await loadModules()
setCSRFToken('csrf-token')
const result = await put('/users/1', { nickname: 'Demo' }, { auth: false })
expect(result).toEqual({ saved: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'PUT',
body: JSON.stringify({ nickname: 'Demo' }),
headers: {
'Content-Type': 'application/json',
[CSRF_HEADER_NAME]: 'csrf-token',
},
})
})
it('adds csrf headers to delete requests', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { deleted: true },
}),
)
const { CSRF_HEADER_NAME, del, setCSRFToken } = await loadModules()
setCSRFToken('csrf-token')
const result = await del('/users/1', { auth: false })
expect(result).toEqual({ deleted: true })
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'DELETE',
headers: {
[CSRF_HEADER_NAME]: 'csrf-token',
},
})
})
it('refreshes an expired access token before sending the business request', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-new',
refresh_token: 'refresh-token-new',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', -1)
setRefreshToken('refresh-token-old')
const data = await get('/protected')
expect(data).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(String(fetchMock.mock.calls[0][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[0][1]).toMatchObject({
credentials: 'include',
method: 'POST',
body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
})
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
Authorization: 'Bearer access-token-new',
})
})
it('waits for an in-flight refresh promise before sending the request', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules()
setAccessToken('queued-access-token', 3600)
startRefreshing()
setRefreshPromise(Promise.resolve())
const result = await get('/protected')
expect(result).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Bearer queued-access-token',
})
})
it('clears the local session when refresh fails before the business request is sent', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('expired-access-token', -1)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('clears the local session when refresh returns a business error payload', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 10001,
message: 'refresh failed',
data: null,
}),
)
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('expired-access-token', -1)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('retries once after a 401 response and rotates the in-memory refresh token', async () => {
const fetchMock = vi.mocked(fetch)
const capturedHeaders: Array<Record<string, string> | undefined> = []
fetchMock
.mockImplementationOnce(async (_url, requestInit) => {
capturedHeaders.push(
requestInit?.headers
? { ...(requestInit.headers as Record<string, string>) }
: undefined,
)
return new Response(null, { status: 401 })
})
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-retried',
refresh_token: 'refresh-token-retried',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockImplementationOnce(async (_url, requestInit) => {
capturedHeaders.push(
requestInit?.headers
? { ...(requestInit.headers as Record<string, string>) }
: undefined,
)
return jsonResponse({
code: 0,
message: 'ok',
data: { retried: true },
})
})
const { get, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
const data = await get('/protected')
expect(data).toEqual({ retried: true })
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(capturedHeaders[0]).toMatchObject({
Authorization: 'Bearer access-token-old',
})
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[1][1]).toMatchObject({
credentials: 'include',
method: 'POST',
body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
})
expect(capturedHeaders[1]).toMatchObject({
Authorization: 'Bearer access-token-retried',
})
expect(getRefreshToken()).toBe('refresh-token-retried')
})
it('reuses an in-flight refresh token when a 401 retry happens during another refresh', async () => {
const fetchMock = vi.mocked(fetch)
const {
get,
setAccessToken,
setRefreshPromise,
startRefreshing,
} = await loadModules()
fetchMock
.mockImplementationOnce(async () => {
startRefreshing()
setAccessToken('shared-refresh-token', 3600)
setRefreshPromise(Promise.resolve())
return new Response(null, { status: 401 })
})
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { retried: true },
}),
)
setAccessToken('access-token-old', 3600)
const data = await get('/protected')
expect(data).toEqual({ retried: true })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
Authorization: 'Bearer shared-refresh-token',
})
})
it('fails the 401 retry when the shared refresh finishes without an access token', async () => {
const fetchMock = vi.mocked(fetch)
const {
clearAccessToken,
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshPromise,
setRefreshToken,
startRefreshing,
} = await loadModules()
fetchMock.mockImplementationOnce(async () => {
startRefreshing()
clearAccessToken()
setRefreshPromise(Promise.resolve())
return new Response(null, { status: 401 })
})
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('clears the local session when the retried request still returns 401', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-retried',
refresh_token: 'refresh-token-retried',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('maps 403 responses to forbidden errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }))
const { ErrorType, get } = await loadModules()
await expect(get('/forbidden', undefined, { auth: false })).rejects.toMatchObject({
status: 403,
type: ErrorType.FORBIDDEN,
})
})
it('maps 404 responses to not-found errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }))
const { ErrorType, get } = await loadModules()
await expect(get('/missing', undefined, { auth: false })).rejects.toMatchObject({
status: 404,
type: ErrorType.NOT_FOUND,
})
})
it('maps other non-ok responses to network errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
const { ErrorType, get } = await loadModules()
await expect(get('/broken', undefined, { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
})
it('maps non-zero business responses to AppError.fromResponse', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 10001,
message: 'business failure',
data: null,
}),
)
const { ErrorType, get } = await loadModules()
await expect(get('/business', undefined, { auth: false })).rejects.toMatchObject({
code: 10001,
status: 200,
type: ErrorType.BUSINESS,
})
})
it('returns null when a successful response carries null data', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: null,
}),
)
const { get } = await loadModules()
const result = await get<null>('/nullable-success', undefined, { auth: false })
expect(result).toBeNull()
})
it('converts aborted requests into timeout AppErrors', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const { ErrorType, request } = await loadModules()
const requestPromise = expect(request('/slow', { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await vi.advanceTimersByTimeAsync(30_000)
await requestPromise
})
it('propagates a caller abort signal into the request timeout controller', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const controller = new AbortController()
const { ErrorType, request } = await loadModules()
const requestPromise = expect(
request('/slow', { auth: false, signal: controller.signal }),
).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await Promise.resolve()
controller.abort()
await requestPromise
})
it('retries downloads after a 401 and returns the blob payload', async () => {
const fetchMock = vi.mocked(fetch)
const downloadedBlob = { kind: 'downloaded-blob' } as unknown as Blob
const successResponse = {
ok: true,
status: 200,
blob: vi.fn().mockResolvedValue(downloadedBlob),
} as unknown as Response
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'download-access-token',
refresh_token: 'download-refresh-token',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(successResponse)
const { download, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
const blob = await download('/export')
expect(blob).toBe(downloadedBlob)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[2][1]?.headers).toMatchObject({
Authorization: 'Bearer download-access-token',
})
expect(getRefreshToken()).toBe('download-refresh-token')
})
it('maps failed downloads to network AppErrors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
const { ErrorType, download } = await loadModules()
await expect(download('/export', undefined, { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
})
it('clears the local session when a download retry still returns 401', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'download-access-token',
refresh_token: 'download-refresh-token',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
download,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(download('/export')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('converts aborted downloads into timeout AppErrors', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const { ErrorType, download } = await loadModules()
const downloadPromise = expect(
download('/export', undefined, { auth: false }),
).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await vi.advanceTimersByTimeAsync(30_000)
await downloadPromise
})
it('builds upload form data with additional fields', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { uploaded: true },
}),
)
const { upload } = await loadModules()
const file = new File(['demo'], 'avatar.png', { type: 'image/png' })
const result = await upload(
'/upload',
file,
'asset',
{ folder: 'avatars' },
{ auth: false },
)
expect(result).toEqual({ uploaded: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const requestInit = fetchMock.mock.calls[0][1]
const body = requestInit?.body as FormData
expect(requestInit?.method).toBe('POST')
expect(body.get('folder')).toBe('avatars')
expect(body.get('asset')).toBeInstanceOf(File)
expect((body.get('asset') as File).name).toBe('avatar.png')
})
})