import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AUTH_STATE_EVENT, ApiClientError, clearStoredAccessToken, fetchWithApiAuth, getStoredAccessToken, isTimeoutError, requestJson, setStoredAccessToken, } from './apiClient'; function createLocalStorageMock() { const store = new Map(); return { getItem(key: string) { return store.has(key) ? store.get(key)! : null; }, setItem(key: string, value: string) { store.set(key, String(value)); }, removeItem(key: string) { store.delete(key); }, clear() { store.clear(); }, }; } function createResponseMock(params: { status: number; body?: string; headers?: Record; }) { const headers = new Map( Object.entries(params.headers ?? {}).map(([key, value]) => [ key.toLowerCase(), value, ]), ); return { status: params.status, ok: params.status >= 200 && params.status < 300, headers: { get(name: string) { return headers.get(name.toLowerCase()) ?? null; }, }, text: vi.fn(async () => params.body ?? ''), }; } describe('apiClient', () => { const fetchMock = vi.fn(); const dispatchEventMock = vi.fn(); beforeEach(() => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('window', { dispatchEvent: dispatchEventMock, localStorage: createLocalStorageMock(), }); fetchMock.mockReset(); dispatchEventMock.mockReset(); clearStoredAccessToken({ emit: false }); }); it('refreshes bearer token once and retries the original request', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { ok: true, token: 'fresh-token', }, error: null, meta: { apiVersion: '2026-04-08', }, }), }), ) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { value: 7, }, error: null, meta: { apiVersion: '2026-04-08', }, }), }), ); const result = await requestJson<{ value: number }>( '/api/runtime/protected', { method: 'GET' }, '读取受保护数据失败', ); expect(result).toEqual({ value: 7 }); expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenNthCalledWith( 1, '/api/runtime/protected', expect.objectContaining({ credentials: 'same-origin', headers: expect.objectContaining({ Authorization: 'Bearer expired-token', 'x-genarrative-response-envelope': 'v1', }), }), ); expect(fetchMock).toHaveBeenNthCalledWith( 2, '/api/auth/refresh', expect.objectContaining({ method: 'POST', credentials: 'same-origin', }), ); expect(fetchMock).toHaveBeenNthCalledWith( 3, '/api/runtime/protected', expect.objectContaining({ credentials: 'same-origin', headers: expect.objectContaining({ Authorization: 'Bearer fresh-token', }), }), ); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('fresh-token'); }); it('hydrates a missing local bearer token before the first protected request', async () => { fetchMock .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { ok: true, token: 'fresh-token', }, error: null, meta: { apiVersion: '2026-04-08', }, }), }), ) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { value: 9, }, error: null, meta: { apiVersion: '2026-04-08', }, }), }), ); const result = await requestJson<{ value: number }>( '/api/runtime/protected', { method: 'GET' }, '读取受保护数据失败', ); expect(result).toEqual({ value: 9 }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenNthCalledWith( 1, '/api/auth/refresh', expect.objectContaining({ method: 'POST', credentials: 'same-origin', }), ); expect(fetchMock).toHaveBeenNthCalledWith( 2, '/api/runtime/protected', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer fresh-token', }), }), ); expect(getStoredAccessToken()).toBe('fresh-token'); }); it('does not emit auth change events when 401 probe requests opt into silent mode', async () => { fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); const response = await fetchWithApiAuth( '/api/auth/me', { method: 'GET', }, { notifyAuthStateChange: false, skipRefresh: true, }, ); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(1); expect(dispatchEventMock).not.toHaveBeenCalled(); }); it('emits auth change events when refresh fails on protected requests', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })); const response = await fetchWithApiAuth('/api/runtime/protected', { method: 'GET', }); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(2); expect(dispatchEventMock).toHaveBeenCalledTimes(1); expect(getStoredAccessToken()).toBe(''); }); it('keeps the refreshed token when the retried protected request is still unauthorized', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { ok: true, token: 'fresh-token', }, error: null, meta: { apiVersion: '2026-04-08', }, }), }), ) .mockResolvedValueOnce(createResponseMock({ status: 401 })); const response = await fetchWithApiAuth('/api/runtime/puzzle/works', { method: 'GET', }); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(3); expect(getStoredAccessToken()).toBe('fresh-token'); expect(dispatchEventMock).not.toHaveBeenCalled(); }); it('rejects refresh responses that do not return a renewed bearer token', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { ok: true, }, error: null, meta: { apiVersion: '2026-04-08', }, }), }), ); await expect( requestJson<{ value: number }>( '/api/runtime/protected', { method: 'GET' }, '读取受保护数据失败', ), ).rejects.toMatchObject({ status: 401, message: '读取受保护数据失败', }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(getStoredAccessToken()).toBe(''); expect(dispatchEventMock).toHaveBeenCalledTimes(1); }); it('keeps the current access token when a public request explicitly skips auth', async () => { setStoredAccessToken('still-valid-token'); vi.mocked(window.dispatchEvent).mockClear(); fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); const response = await fetchWithApiAuth( '/api/runtime/custom-world-gallery', { method: 'GET' }, { skipAuth: true, skipRefresh: true, }, ); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( '/api/runtime/custom-world-gallery', expect.objectContaining({ headers: expect.not.objectContaining({ Authorization: expect.any(String), }), }), ); expect(getStoredAccessToken()).toBe('still-valid-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); }); it('retries transient get requests before unwrapping the response envelope', async () => { fetchMock .mockRejectedValueOnce(new TypeError('network unavailable')) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { value: 42, }, error: null, meta: { apiVersion: '2026-04-08', }, }), headers: { 'Content-Type': 'application/json', }, }), ); const result = await requestJson<{ value: number }>( '/api/runtime/settings', { method: 'GET' }, '读取设置失败', ); expect(fetchMock).toHaveBeenCalledTimes(2); expect(result).toEqual({ value: 42 }); }); it('aborts requests when timeoutMs is reached', async () => { setStoredAccessToken('timeout-token', { emit: false }); fetchMock.mockImplementation( async (_input: string, init?: RequestInit) => new Promise((_resolve, reject) => { init?.signal?.addEventListener( 'abort', () => { reject(init.signal?.reason); }, { once: true }, ); }), ); let capturedError: unknown; try { await requestJson( '/api/runtime/protected', { method: 'POST' }, '创建会话失败', { timeoutMs: 20, skipRefresh: true, }, ); } catch (error) { capturedError = error; } expect(fetchMock).toHaveBeenCalledTimes(1); expect(isTimeoutError(capturedError)).toBe(true); expect(capturedError).toBeInstanceOf(Error); }); it('surfaces response metadata through ApiClientError', async () => { setStoredAccessToken('metadata-token', { emit: false }); fetchMock.mockResolvedValueOnce( createResponseMock({ status: 503, body: JSON.stringify({ ok: false, data: null, error: { code: 'UPSTREAM_ERROR', message: '上游暂不可用', details: { scope: 'runtime', }, }, meta: { apiVersion: '2026-04-08', requestId: 'req-body', }, }), headers: { 'Content-Type': 'application/json', 'x-request-id': 'req-header', 'x-route-version': 'runtime.v2', }, }), ); let capturedError: unknown; try { await requestJson( '/api/runtime/story/initial', { method: 'POST' }, '剧情生成失败', ); } catch (error) { capturedError = error; } expect(capturedError).toBeInstanceOf(ApiClientError); expect(capturedError).toMatchObject({ status: 503, code: 'UPSTREAM_ERROR', details: { scope: 'runtime', }, meta: { requestId: 'req-body', routeVersion: 'runtime.v2', }, }); }); });