Files
story/web/test/api-client-timeout.test.mjs
T

148 lines
6.2 KiB
JavaScript
Raw Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import vm from 'node:vm';
const require = createRequire(import.meta.url);
const ts = require('typescript');
const source = readFileSync(process.env.API_CLIENT_TEST_SOURCE || new URL('../src/api/client.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
function loadClient(fetchImpl = fetch) {
const timers = new Map();
const delays = [];
const events = [];
let sequence = 0;
let notifyHeaders;
const headers = new Promise(resolve => { notifyHeaders = resolve; });
const exports = {};
vm.runInNewContext(compiled, {
exports,
require: name => {
assert.equal(name, '../auth/portal');
return { isOperatorPortal: () => false, phoneCSRFKey: () => 'test-csrf' };
},
AbortController, DOMException, Event, URLSearchParams,
sessionStorage: { getItem: () => null },
window: {
setTimeout: (callback, delay) => { const id = ++sequence; timers.set(id, callback); delays.push(delay); return id; },
clearTimeout: id => timers.delete(id),
dispatchEvent: event => { events.push(event.type); return true; }
},
fetch: async (...args) => { const response = await fetchImpl(...args); notifyHeaders(); return response; }
});
return {
...exports, timers, delays, events, headers,
expire: () => {
assert.equal(timers.size, 1, 'deadline must remain active after headers');
timers.values().next().value();
}
};
}
async function stalledResponse(t, status = 200) {
const server = createServer((_request, response) => {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.flushHeaders();
response.write('{');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => {
server.closeAllConnections();
return new Promise(resolve => server.close(resolve));
});
return `http://127.0.0.1:${server.address().port}/api/v1/subjects/test`;
}
async function waitForBody(api) {
await api.headers;
// Let fetch's header continuation settle while the server keeps the body open.
await new Promise(resolve => setImmediate(resolve));
}
test('a saved mutation whose response body stalls is bounded and is not retried', { timeout: 5000 }, async t => {
const url = await stalledResponse(t);
let requests = 0;
const api = loadClient((...args) => { requests++; return fetch(...args); });
const outcome = api.authenticatedJSON('PATCH', url, '', undefined, { preferred_name: 'synthetic' }).catch(error => error);
await waitForBody(api);
api.expire();
const error = await outcome;
assert.ok(error instanceof api.APIRequestError);
assert.equal(error.code, 'REQUEST_TIMEOUT');
assert.equal(error.status, 0);
assert.equal(error.message, '保存结果尚未确认,请先核验服务器记录');
assert.equal(requests, 1);
assert.deepEqual(api.delays, [15000]);
assert.equal(api.timers.size, 0);
assert.deepEqual(api.events, []);
});
test('GET body timeout retains read-only recovery guidance', { timeout: 5000 }, async t => {
const api = loadClient();
const outcome = api.authenticatedJSON('GET', await stalledResponse(t), '').catch(error => error);
await waitForBody(api);
api.expire();
const error = await outcome;
assert.equal(error.code, 'REQUEST_TIMEOUT');
assert.match(error.message, /请求超时/);
assert.doesNotMatch(error.message, /保存结果/);
});
test('a stalled HTTP error body is bounded without revoking authentication', { timeout: 5000 }, async t => {
const api = loadClient();
const outcome = api.authenticatedJSON('PATCH', await stalledResponse(t, 502), '').catch(error => error);
await waitForBody(api);
api.expire();
assert.equal((await outcome).code, 'REQUEST_TIMEOUT');
assert.deepEqual(api.events, []);
});
test('caller cancellation while reading an error body is not a timeout or auth failure', { timeout: 5000 }, async t => {
const api = loadClient();
const caller = new AbortController();
const outcome = api.authenticatedJSON('GET', await stalledResponse(t, 401), '', caller.signal).catch(error => error);
await waitForBody(api);
caller.abort();
const error = await outcome;
assert.equal(error.name, 'AbortError');
assert.equal(error.code, DOMException.ABORT_ERR);
assert.equal(api.timers.size, 0);
assert.deepEqual(api.events, []);
});
test('successful JSON responses clear their timers and preserve creation timeout', async () => {
const api = loadClient(async () => Response.json({ data: { id: 'synthetic' } }));
const result = await api.authenticatedJSON('POST', '/api/v1/subjects', '', undefined, { preferred_name: 'synthetic' });
assert.equal(result.data.id, 'synthetic');
assert.deepEqual(api.delays, [55000]);
assert.equal(api.timers.size, 0);
});
test('completed 502 HTML preserves the HTTP failure without authentication events', async () => {
const api = loadClient(async () => new Response('<h1>Bad Gateway</h1>', { status: 502 }));
const error = await api.authenticatedJSON('PATCH', '/api/v1/subjects/test', '').catch(value => value);
assert.ok(error instanceof api.APIRequestError);
assert.equal(error.status, 502);
assert.deepEqual(api.events, []);
assert.equal(api.timers.size, 0);
});
test('completed 401 JSON still requests the existing authentication check', async () => {
const api = loadClient(async () => Response.json({ error: { code: 'UNAUTHORIZED', message: 'synthetic' } }, { status: 401 }));
const error = await api.authenticatedJSON('GET', '/api/v1/me', '').catch(value => value);
assert.equal(error.status, 401);
assert.equal(error.code, 'UNAUTHORIZED');
assert.deepEqual(api.events, [api.AUTHENTICATION_REQUIRED_EVENT]);
});
test('invalid successful JSON remains an unconfirmed response rather than success', async () => {
const api = loadClient(async () => new Response('{', { status: 200 }));
const error = await api.authenticatedJSON('PATCH', '/api/v1/subjects/test', '').catch(value => value);
assert.equal(error.name, 'SyntaxError');
assert.equal(api.timers.size, 0);
assert.deepEqual(api.events, []);
});