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 | 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 | undefined> = [] fetchMock .mockImplementationOnce(async (_url, requestInit) => { capturedHeaders.push( requestInit?.headers ? { ...(requestInit.headers as Record) } : 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) } : 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('/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') }) })