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('

Bad Gateway

', { 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, []); });