From 64fb00590fbc129f442ecab188bf88847ee3177a Mon Sep 17 00:00:00 2001 From: kongkx Date: Tue, 7 Jul 2026 22:47:02 +0800 Subject: [PATCH] test(api): client envelope/refresh/error tests; docs: README; fix: synchronous single-flight refresh reset --- src/api/client.test.ts | 113 +++++++++++++++++++++++++++++++++++++++++ src/api/client.ts | 12 ++--- 2 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/api/client.test.ts diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..f3f355d --- /dev/null +++ b/src/api/client.test.ts @@ -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() + }) +}) diff --git a/src/api/client.ts b/src/api/client.ts index 0401bb7..c25e215 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -61,15 +61,15 @@ async function tryRefresh(): Promise { 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 {