test(api): client envelope/refresh/error tests; docs: README; fix: synchronous single-flight refresh reset
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+6
-6
@@ -61,15 +61,15 @@ async function tryRefresh(): Promise<boolean> {
|
|||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
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> {
|
async function parseError(res: Response): Promise<ApiError> {
|
||||||
|
|||||||
Reference in New Issue
Block a user