Files
Genarrative/src/services/apiClient.test.ts
高物 74fd9a33ac Increase VectorEngine timeouts and add image UI
Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
2026-05-15 02:40:59 +08:00

675 lines
18 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(),
});
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 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 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/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',
},
});
});
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',
},
});
});
});