- 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
802 lines
22 KiB
TypeScript
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')
|
|
})
|
|
})
|