Files
Genarrative/src/services/apiClient.test.ts
kdletters f8a80cd795 修复资产计费边界风险
资产生成预扣费改为 fail-closed,避免钱包异常时继续调用外部生成

新增钱包退款 outbox,退款失败时本地落盘并后台重放

拼图首图后台任务改用 SpacetimeDB claim 表实现跨实例互斥

计费 ledger id 统一绑定 request_id,并让前端重试复用 x-request-id

同步 SpacetimeDB bindings、后端架构文档和 Hermes 决策记录
2026-06-11 15:55:23 +08:00

736 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AUTH_STATE_EVENT,
ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
clearStoredAccessToken,
fetchWithApiAuth,
getStoredAccessToken,
isTimeoutError,
refreshStoredAccessToken,
requestJson,
setStoredAccessToken,
} from './apiClient';
function createLocalStorageMock() {
const store = new Map<string, string>();
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<string, string>;
}) {
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(),
});
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-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-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-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 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-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 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-04-08',
},
}),
}),
);
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-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/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',
},
});
});
});