test(api): client envelope/refresh/error tests; docs: README; fix: synchronous single-flight refresh reset

This commit is contained in:
2026-07-07 22:47:02 +08:00
parent 61cb549e72
commit 64fb00590f
2 changed files with 119 additions and 6 deletions

113
src/api/client.test.ts Normal file
View 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()
})
})

View File

@ -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> {