fix(design): bound requests and prevent mutation replay
This commit is contained in:
@@ -35,6 +35,36 @@ describe('proxyAwareFetch', () => {
|
||||
expect(net.fetch).toHaveBeenCalledWith('https://example.test', undefined);
|
||||
});
|
||||
|
||||
it('does not replay a mutation through global fetch after Electron net.fetch fails', async () => {
|
||||
setElectronVersion('43.4.0');
|
||||
const transportError = new Error('response transport failed');
|
||||
const fetchMock = vi.fn();
|
||||
vi.mocked(net.fetch).mockRejectedValue(transportError);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(proxyAwareFetch('https://example.test/quote', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ aspect_ratio: '16:9' }),
|
||||
})).rejects.toBe(transportError);
|
||||
|
||||
expect(net.fetch).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains the global fallback for safe reads after Electron net.fetch fails', async () => {
|
||||
setElectronVersion('43.4.0');
|
||||
const response = new Response('node fallback');
|
||||
const fetchMock = vi.fn().mockResolvedValue(response);
|
||||
vi.mocked(net.fetch).mockRejectedValue(new Error('electron transport failed'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(proxyAwareFetch('https://example.test/read', {
|
||||
method: 'GET',
|
||||
})).resolves.toBe(response);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.test/read', { method: 'GET' });
|
||||
});
|
||||
|
||||
it('uses the global fetch outside Electron', async () => {
|
||||
setElectronVersion(undefined);
|
||||
const response = new Response('node');
|
||||
|
||||
@@ -627,6 +627,95 @@ describe('Works Square AI design adapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts a stuck Quote request at the design request deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
const fetchMock = vi.fn<typeof fetch>((_input, init) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
return new Promise<Response>(() => undefined);
|
||||
});
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const outcome = adapter.updateGenerationQuote({
|
||||
workspaceId: 'workspace-one',
|
||||
quoteId: 'quote-one',
|
||||
finalPrompt: 'updated prompt',
|
||||
generationParameters: quoteConfirmationInput.generationParameters,
|
||||
}).then(
|
||||
() => ({ state: 'resolved' as const }),
|
||||
(error: unknown) => ({ state: 'rejected' as const, error }),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(Promise.race([
|
||||
outcome,
|
||||
Promise.resolve({ state: 'pending' as const }),
|
||||
])).resolves.toMatchObject({
|
||||
state: 'rejected',
|
||||
error: {
|
||||
status: 504,
|
||||
code: 'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
|
||||
message: 'AI 设计服务响应超时,请重试',
|
||||
},
|
||||
});
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('also bounds a Quote response whose JSON body never finishes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
const fetchMock = vi.fn<typeof fetch>((_input, init) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
|
||||
start: () => undefined,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
});
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const outcome = adapter.updateGenerationQuote({
|
||||
workspaceId: 'workspace-one',
|
||||
quoteId: 'quote-one',
|
||||
finalPrompt: 'updated prompt',
|
||||
generationParameters: quoteConfirmationInput.generationParameters,
|
||||
}).then(
|
||||
() => ({ state: 'resolved' as const }),
|
||||
(error: unknown) => ({ state: 'rejected' as const, error }),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(Promise.race([
|
||||
outcome,
|
||||
Promise.resolve({ state: 'pending' as const }),
|
||||
])).resolves.toMatchObject({
|
||||
state: 'rejected',
|
||||
error: {
|
||||
status: 504,
|
||||
code: 'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
|
||||
},
|
||||
});
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes the server video option shape so the duration picker stays available', async () => {
|
||||
const videoConversation = {
|
||||
...serverConversation,
|
||||
@@ -1731,6 +1820,7 @@ describe('Works Square AI design adapter', () => {
|
||||
expect(getTokenMock).toHaveBeenNthCalledWith(2, {
|
||||
fetchImpl: fetchMock,
|
||||
forceRefresh: true,
|
||||
requestTimeoutMs: 30_000,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
|
||||
@@ -284,6 +284,53 @@ describe('works-square-session service', () => {
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('expires a stuck shared refresh and permits the next refresh attempt', async () => {
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
const fetchImpl = vi.fn<typeof fetch>((_input, init) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
return new Promise<Response>(() => undefined);
|
||||
});
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'old-access-token',
|
||||
refreshToken: 'old-refresh-token',
|
||||
expiresAt: Date.now() + 10_000,
|
||||
});
|
||||
|
||||
const first = getValidWorksSquareAccessToken({ fetchImpl, requestTimeoutMs: 25 });
|
||||
const second = getValidWorksSquareAccessToken({ fetchImpl, requestTimeoutMs: 25 });
|
||||
const outcomes = Promise.allSettled([first, second]);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(Promise.race([
|
||||
outcomes,
|
||||
Promise.resolve('pending'),
|
||||
])).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
status: 'rejected',
|
||||
reason: expect.objectContaining({ name: 'RequestDeadlineExceededError' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
status: 'rejected',
|
||||
reason: expect.objectContaining({ name: 'RequestDeadlineExceededError' }),
|
||||
}),
|
||||
]);
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
|
||||
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}), { status: 200 }));
|
||||
|
||||
await expect(getValidWorksSquareAccessToken({
|
||||
fetchImpl,
|
||||
requestTimeoutMs: 25,
|
||||
})).resolves.toBe('new-access-token');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('still allows refresh one millisecond before the seven-day boundary', async () => {
|
||||
const lastActiveAt = Date.now();
|
||||
const nowMs = lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS - 1;
|
||||
|
||||
Reference in New Issue
Block a user