148 lines
6.2 KiB
JavaScript
148 lines
6.2 KiB
JavaScript
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, []);
|
||
|
|
});
|