import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, BACKGROUND_AUTH_REQUEST_OPTIONS, clearStoredAccessToken, fetchWithApiAuth, getStoredAccessToken, isTimeoutError, refreshStoredAccessToken, 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 ?? ''), }; } function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } describe('apiClient', () => { const fetchMock = vi.fn(); const dispatchEventMock = vi.fn(); beforeEach(() => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('window', { dispatchEvent: dispatchEventMock, localStorage: createLocalStorageMock(), }); vi.stubGlobal('crypto', { randomUUID: () => '11111111-2222-3333-4444-555555555555', }); 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: { token: 'fresh-token', }, error: null, meta: { apiVersion: '2026-06-16', }, }), }), ) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { value: 7, }, error: null, meta: { apiVersion: '2026-06-16', }, }), }), ); 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-request-id': 'web-11111111-2222-3333-4444-555555555555', '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', 'x-request-id': 'web-11111111-2222-3333-4444-555555555555', }), }), ); 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: { token: 'fresh-token', }, error: null, meta: { apiVersion: '2026-06-16', }, }), }), ) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { value: 9, }, error: null, meta: { apiVersion: '2026-06-16', }, }), }), ); 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('attaches mini program client headers to refresh and protected requests', async () => { Object.assign(window, { location: { search: '?clientRuntime=wechat_mini_program&hostPlatform=ios&miniProgramEnv=release', }, wx: { miniProgram: { postMessage: vi.fn(), }, }, }); fetchMock .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { token: 'mini-token', }, error: null, meta: { apiVersion: '2026-06-16', }, }), }), ) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { value: 11, }, error: null, meta: { apiVersion: '2026-06-16', }, }), }), ); const result = await requestJson<{ value: number }>( '/api/runtime/protected', { method: 'GET' }, '读取受保护数据失败', ); expect(result).toEqual({ value: 11 }); expect(fetchMock).toHaveBeenNthCalledWith( 1, '/api/auth/refresh', expect.objectContaining({ headers: expect.objectContaining({ 'x-client-type': 'mini_program', 'x-client-runtime': 'wechat_mini_program', 'x-client-platform': 'ios', 'x-mini-program-env': 'release', }), }), ); expect(fetchMock).toHaveBeenNthCalledWith( 2, '/api/runtime/protected', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer mini-token', 'x-client-type': 'mini_program', 'x-client-runtime': 'wechat_mini_program', 'x-client-platform': 'ios', 'x-mini-program-env': 'release', }), }), ); }); 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 auth state untouched when local background requests opt out of unauthorized clearing', async () => { setStoredAccessToken('still-valid-token', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })); const response = await fetchWithApiAuth( '/api/runtime/puzzle/runs', { method: 'POST' }, { skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, ); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(1); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('still-valid-token'); }); it('keeps auth state untouched when local auth impact receives 401 with bearer token', async () => { setStoredAccessToken('still-valid-token', { emit: false }); fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); const response = await fetchWithApiAuth( '/api/profile/save-archives', { method: 'GET' }, { authImpact: 'local', }, ); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(1); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('still-valid-token'); }); it('does not clear local token when background refresh fails before a request', async () => { setStoredAccessToken('still-valid-token', { emit: false }); fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); const response = await fetchWithApiAuth( '/api/runtime/puzzle/runs', { method: 'POST' }, BACKGROUND_AUTH_REQUEST_OPTIONS, ); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( '/api/runtime/puzzle/runs', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer still-valid-token', }), }), ); expect(fetchMock).not.toHaveBeenCalledWith( '/api/auth/refresh', expect.anything(), ); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('still-valid-token'); }); it('keeps local token when explicit refresh opts out of clearing on failure', async () => { setStoredAccessToken('usable-local-token', { emit: false }); fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect( refreshStoredAccessToken({ clearOnFailure: false }), ).rejects.toMatchObject({ status: 401, }); expect(fetchMock).toHaveBeenCalledWith( '/api/auth/refresh', expect.objectContaining({ method: 'POST', credentials: 'same-origin', }), ); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('usable-local-token'); }); it('keeps local token when refresh fails with transient server unavailable', async () => { setStoredAccessToken('usable-local-token', { emit: false }); fetchMock.mockResolvedValueOnce(createResponseMock({ status: 503 })); await expect(refreshStoredAccessToken()).rejects.toMatchObject({ status: 503, }); expect(fetchMock).toHaveBeenCalledWith( '/api/auth/refresh', expect.objectContaining({ method: 'POST', credentials: 'same-origin', }), ); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('usable-local-token'); }); it('keeps local token when refresh cannot reach the restarting server', async () => { setStoredAccessToken('usable-local-token', { emit: false }); fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); await expect(refreshStoredAccessToken()).rejects.toBeInstanceOf(TypeError); expect(fetchMock).toHaveBeenCalledTimes(1); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('usable-local-token'); }); it('clears local token when refresh confirms the session is unauthorized', async () => { setStoredAccessToken('expired-local-token', { emit: false }); fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect(refreshStoredAccessToken()).rejects.toMatchObject({ status: 401, }); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe(''); }); it('does not clear auth when protected request refresh fails transiently', async () => { setStoredAccessToken('expired-token-during-restart', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 503 })); const response = await fetchWithApiAuth('/api/runtime/protected', { method: 'GET', }); expect(response.status).toBe(401); expect(fetchMock).toHaveBeenCalledTimes(2); expect(dispatchEventMock).not.toHaveBeenCalled(); expect(getStoredAccessToken()).toBe('expired-token-during-restart'); }); 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: { token: 'fresh-token', }, error: null, meta: { apiVersion: '2026-06-16', }, }), }), ) .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 malformed refresh responses without treating them as logout', 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-06-16', }, }), }), ); await expect( requestJson<{ value: number }>( '/api/runtime/protected', { method: 'GET' }, '读取受保护数据失败', ), ).rejects.toMatchObject({ status: 401, message: '读取受保护数据失败', }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(getStoredAccessToken()).toBe('expired-token'); expect(dispatchEventMock).not.toHaveBeenCalled(); }); 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-06-16', }, }), 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('reuses the exact post body and request id for an enabled unsafe transport retry', async () => { setStoredAccessToken('editor-agent-token', { emit: false }); fetchMock .mockRejectedValueOnce(new TypeError('network unavailable')) .mockResolvedValueOnce( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { deltaMessages: [] }, error: null, meta: { apiVersion: '2026-06-16' }, }), headers: { 'Content-Type': 'application/json' }, }), ); const body = JSON.stringify({ clientMessageId: 'client-message-1', text: '生成一张图', attachments: [], }); await requestJson( '/api/editor/agent-conversations/conversation-1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }, '发送画布 Agent 消息失败', { authImpact: 'local', retry: { maxRetries: 1, baseDelayMs: 1, maxDelayMs: 1, retryUnsafeMethods: true, }, }, ); expect(fetchMock).toHaveBeenCalledTimes(2); const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit; const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit; expect(firstInit.body).toBe(body); expect(secondInit.body).toBe(body); expect((firstInit.headers as Record)['x-request-id']).toBe( 'web-11111111-2222-3333-4444-555555555555', ); expect((secondInit.headers as Record)['x-request-id']).toBe( (firstInit.headers as Record)['x-request-id'], ); }); it('does not retry an unsafe write with a new token after its account signal aborts', async () => { setStoredAccessToken('account-a-token', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 503 })) .mockResolvedValueOnce(createResponseMock({ status: 200 })); const abortController = new AbortController(); const request = requestJson( '/api/profile/redeem-codes/redeem', { method: 'POST', body: JSON.stringify({ code: 'REWARD01' }), signal: abortController.signal, }, '兑换失败', { retry: { maxRetries: 1, baseDelayMs: 1000, maxDelayMs: 1000, retryUnsafeMethods: true, }, }, ); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); }); setStoredAccessToken('account-b-token', { emit: false }); abortController.abort(); await expect(request).rejects.toMatchObject({ name: 'AbortError' }); expect(fetchMock).toHaveBeenCalledTimes(1); expect( (fetchMock.mock.calls[0]?.[1]?.headers as Record) .Authorization, ).toBe('Bearer account-a-token'); }); it('does not replay a 401 unsafe write after its account signal aborts during refresh', async () => { setStoredAccessToken('account-a-token', { emit: false }); const refreshResponse = createDeferred>(); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockImplementationOnce(() => refreshResponse.promise) .mockResolvedValueOnce(createResponseMock({ status: 200 })); const abortController = new AbortController(); const request = requestJson( '/api/profile/redeem-codes/redeem', { method: 'POST', body: JSON.stringify({ code: 'REWARD01' }), signal: abortController.signal, }, '兑换失败', ); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); const accountARefresh = refreshStoredAccessToken({ clearOnFailure: false, }); const accountARefreshResult = expect(accountARefresh).rejects.toMatchObject( { name: 'AuthStateChangedDuringRefreshError', }, ); setStoredAccessToken('account-b-token', { emit: false }); abortController.abort(); await expect(request).rejects.toMatchObject({ name: 'AbortError' }); refreshResponse.resolve( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { token: 'late-refresh-token' }, error: null, meta: { apiVersion: '2026-06-16' }, }), }), ); await accountARefreshResult; expect(fetchMock).toHaveBeenCalledTimes(2); expect(getStoredAccessToken()).toBe('account-b-token'); expect(fetchMock.mock.calls[0]?.[0]).toBe( '/api/profile/redeem-codes/redeem', ); expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh'); }); it('starts a separate refresh for a new auth generation', async () => { setStoredAccessToken('account-a-token', { emit: false }); const accountAResponse = createDeferred>(); const accountBResponse = createDeferred>(); fetchMock .mockImplementationOnce(() => accountAResponse.promise) .mockImplementationOnce(() => accountBResponse.promise); const accountARefresh = refreshStoredAccessToken({ clearOnFailure: false }); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); }); setStoredAccessToken('account-b-token', { emit: false }); const accountBRefresh = refreshStoredAccessToken({ clearOnFailure: false }); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); accountAResponse.resolve( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { token: 'late-account-a-token' }, error: null, meta: { apiVersion: '2026-06-16' }, }), }), ); await expect(accountARefresh).rejects.toMatchObject({ name: 'AuthStateChangedDuringRefreshError', }); expect(getStoredAccessToken()).toBe('account-b-token'); accountBResponse.resolve( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { token: 'fresh-account-b-token' }, error: null, meta: { apiVersion: '2026-06-16' }, }), }), ); await expect(accountBRefresh).resolves.toBe('fresh-account-b-token'); expect(getStoredAccessToken()).toBe('fresh-account-b-token'); }); it('does not clear a new account token when an old refresh fails late', async () => { setStoredAccessToken('account-a-token', { emit: false }); const accountAResponse = createDeferred>(); fetchMock.mockImplementationOnce(() => accountAResponse.promise); const accountARefresh = refreshStoredAccessToken(); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); }); setStoredAccessToken('account-b-token', { emit: false }); accountAResponse.resolve(createResponseMock({ status: 401 })); await expect(accountARefresh).rejects.toMatchObject({ status: 401 }); expect(getStoredAccessToken()).toBe('account-b-token'); }); 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.each(['missing-token', 'unauthorized'] as const)( 'bounds the %s refresh wait with an absolute request deadline', async (refreshMode) => { const refreshResponse = createDeferred>(); if (refreshMode === 'unauthorized') { setStoredAccessToken('expired-token', { emit: false }); fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockImplementationOnce(() => refreshResponse.promise); } else { fetchMock.mockImplementationOnce(() => refreshResponse.promise); } const request = requestJson( '/api/runtime/protected', { method: 'GET' }, '读取受保护数据失败', { deadlineAt: Date.now() + 20 }, ); await expect(request).rejects.toMatchObject({ name: 'TimeoutError' }); expect(fetchMock).toHaveBeenCalledTimes( refreshMode === 'unauthorized' ? 2 : 1, ); expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('/api/auth/refresh'); if (refreshMode === 'unauthorized') { expect(getStoredAccessToken()).toBe('expired-token'); } else { expect(getStoredAccessToken()).toBe(''); } expect(dispatchEventMock).not.toHaveBeenCalled(); refreshResponse.resolve( createResponseMock({ status: 200, body: JSON.stringify({ ok: true, data: { token: 'late-refresh-token' }, error: null, meta: { apiVersion: '2026-06-16' }, }), }), ); await vi.waitFor(() => { expect(getStoredAccessToken()).toBe('late-refresh-token'); }); expect(dispatchEventMock).not.toHaveBeenCalled(); }, ); it.each([200, 400])( 'bounds a %i response body read with the same absolute request deadline', async (status) => { setStoredAccessToken('body-timeout-token', { emit: false }); const responseBody = createDeferred(); const response = createResponseMock({ status }); response.text.mockImplementationOnce(() => responseBody.promise); fetchMock.mockResolvedValueOnce(response); const request = requestJson( '/api/runtime/protected', { method: 'GET' }, '读取受保护数据失败', { deadlineAt: Date.now() + 20 }, ); await expect(request).rejects.toMatchObject({ name: 'TimeoutError' }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(response.text).toHaveBeenCalledTimes(1); responseBody.resolve(''); await responseBody.promise; }, ); 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-06-16', 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/protected-error', { 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', }, }); expect((capturedError as Error).message).toContain('requestId: req-body'); }); it('uses api error details.message as ApiClientError message', async () => { setStoredAccessToken('details-message-token', { emit: false }); fetchMock.mockResolvedValueOnce( createResponseMock({ status: 400, body: JSON.stringify({ ok: false, data: null, error: { code: 'BAD_REQUEST', message: '请求参数不合法', details: { provider: 'dashscope', message: '拼图图片生成失败:请求参数不合法', }, }, meta: {}, }), headers: { 'Content-Type': 'application/json', }, }), ); await expect( requestJson( '/api/runtime/puzzle/agent/sessions/test/actions', { method: 'POST', }, '执行拼图操作失败。', ), ).rejects.toMatchObject({ message: '拼图图片生成失败:请求参数不合法', status: 400, code: 'BAD_REQUEST', details: { provider: 'dashscope', }, }); }); it('prefers api error details.reason over details.message for diagnostics', async () => { setStoredAccessToken('details-reason-first-token', { emit: false }); fetchMock.mockResolvedValueOnce( createResponseMock({ status: 502, body: JSON.stringify({ ok: false, data: null, error: { code: 'UPSTREAM_ERROR', message: '上游暂不可用', details: { provider: 'vector-engine', message: '创建拼图 VectorEngine 图片编辑任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/edits)', reason: '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', endpoint: 'https://api.vectorengine.ai/v1/images/edits', }, }, meta: {}, }), headers: { 'Content-Type': 'application/json', }, }), ); await expect( requestJson( '/api/runtime/puzzle/agent/sessions/test/actions', { method: 'POST', }, '执行拼图操作失败。', ), ).rejects.toMatchObject({ message: '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', status: 502, code: 'UPSTREAM_ERROR', details: { provider: 'vector-engine', }, }); }); it('uses api error details.reason when details.message is absent', async () => { setStoredAccessToken('details-reason-token', { emit: false }); fetchMock.mockResolvedValueOnce( createResponseMock({ status: 503, body: JSON.stringify({ ok: false, data: null, error: { code: 'SERVICE_UNAVAILABLE', message: '服务暂不可用', details: { provider: 'vector-engine', reason: 'VECTOR_ENGINE_API_KEY 未配置', }, }, meta: {}, }), headers: { 'Content-Type': 'application/json', }, }), ); await expect( requestJson( '/api/creation/match3d/sessions/test/actions', { method: 'POST', }, '执行抓大鹅共创操作失败', ), ).rejects.toMatchObject({ message: 'VECTOR_ENGINE_API_KEY 未配置', status: 503, code: 'SERVICE_UNAVAILABLE', details: { provider: 'vector-engine', }, }); }); });