test(api): client envelope/refresh/error tests; docs: README; fix: synchronous single-flight refresh reset
This commit is contained in:
113
src/api/client.test.ts
Normal file
113
src/api/client.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, api } from './client'
|
||||
import { useAuthStore } from '@/auth/store'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
useAuthStore.getState().clear()
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
it('unwraps the success envelope and sends locale + bearer headers', async () => {
|
||||
useAuthStore.getState().setTokens({
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
access_token: 'token-1',
|
||||
refresh_token: 'refresh-1',
|
||||
})
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: { id: 1 } }))
|
||||
|
||||
const result = await api.get<{ success: boolean; data: { id: number } }>('/api/admin/v1/x')
|
||||
|
||||
expect(result.data.id).toBe(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toContain('/api/admin/v1/x')
|
||||
expect(init.headers.Authorization).toBe('Bearer token-1')
|
||||
expect(init.headers['X-Language-Locale']).toBe('zh-CN')
|
||||
})
|
||||
|
||||
it('maps error envelopes to ApiError with code and field errors', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '验证失败',
|
||||
data: { errors: { slug: ['slug 已存在'] } },
|
||||
},
|
||||
422,
|
||||
),
|
||||
)
|
||||
|
||||
const error = await api.post('/api/admin/v1/x', {}).catch((e: unknown) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
expect((error as ApiError).status).toBe(422)
|
||||
expect((error as ApiError).code).toBe('VALIDATION_ERROR')
|
||||
expect((error as ApiError).errors?.slug[0]).toBe('slug 已存在')
|
||||
})
|
||||
|
||||
it('refreshes once on 401 and retries with the new token', async () => {
|
||||
useAuthStore.getState().setTokens({
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
access_token: 'expired',
|
||||
refresh_token: 'refresh-1',
|
||||
})
|
||||
|
||||
fetchMock
|
||||
// original request → 401
|
||||
.mockResolvedValueOnce(jsonResponse({ success: false, code: 'UNAUTHENTICATED' }, 401))
|
||||
// refresh call → new grant
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: { token_type: 'Bearer', expires_in: 3600, access_token: 'fresh', refresh_token: 'refresh-2' },
|
||||
}),
|
||||
)
|
||||
// retried request → ok
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, data: 'ok' }))
|
||||
|
||||
const result = await api.get<{ success: boolean; data: string }>('/api/admin/v1/x')
|
||||
|
||||
expect(result.data).toBe('ok')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('/api/v1/auth/refresh-token')
|
||||
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('Bearer fresh')
|
||||
expect(useAuthStore.getState().refreshToken).toBe('refresh-2')
|
||||
})
|
||||
|
||||
it('clears the session when refresh fails', async () => {
|
||||
useAuthStore.getState().setTokens({
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
access_token: 'expired',
|
||||
refresh_token: 'dead',
|
||||
})
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ success: false, code: 'UNAUTHENTICATED' }, 401))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: false, code: 'INVALID_REFRESH' }, 401))
|
||||
|
||||
const error = await api.get('/api/admin/v1/x').catch((e: unknown) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
expect((error as ApiError).status).toBe(401)
|
||||
expect(useAuthStore.getState().accessToken).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -61,15 +61,15 @@ async function tryRefresh(): Promise<boolean> {
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
// allow the next expiry to trigger a fresh attempt
|
||||
setTimeout(() => {
|
||||
refreshInFlight = null
|
||||
}, 0)
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshInFlight
|
||||
try {
|
||||
return await refreshInFlight
|
||||
} finally {
|
||||
// reset once settled so the next expiry triggers a fresh attempt
|
||||
refreshInFlight = null
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<ApiError> {
|
||||
|
||||
Reference in New Issue
Block a user