Files
MyIP/tests/fetch-with-timeout.test.js
T
jason5ng32andClaude Fable 5 d5146c8ce7 Improvements
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:29:05 +08:00

245 lines
9.1 KiB
JavaScript

import assert from 'node:assert/strict';
import { describe, it, afterEach } from 'node:test';
import { fetchWithTimeout, fetchUpstream, setUpstreamUserAgent } from '../common/fetch-with-timeout.js';
// Stub global fetch per-test so we can control whether it resolves, rejects,
// or stays pending (and observe whether the wrapper aborted it via signal).
const originalFetch = globalThis.fetch;
function installFetch(impl) {
globalThis.fetch = (input, init) => impl(input, init);
}
function restoreFetch() {
globalThis.fetch = originalFetch;
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('fetchWithTimeout()', () => {
afterEach(() => {
restoreFetch();
});
it('resolves with the response when fetch finishes before the timeout', async () => {
installFetch(async () => new Response('ok'));
const res = await fetchWithTimeout('https://example.test', { timeoutMs: 100 });
assert.equal(await res.text(), 'ok');
});
it('aborts the fetch when the timeout elapses, surfacing an AbortError', async () => {
let sawAbort = false;
installFetch((_input, init) => {
const { promise, reject } = deferred();
init.signal.addEventListener('abort', () => {
sawAbort = true;
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
return promise;
});
await assert.rejects(
() => fetchWithTimeout('https://example.test', { timeoutMs: 20 }),
(err) => err.name === 'AbortError',
);
assert.equal(sawAbort, true, 'fetch received the abort signal from the timeout');
});
it('clears the timer and does not abort when fetch resolves quickly', async () => {
let sawAbortAfterResolve = false;
installFetch((_input, init) => {
init.signal.addEventListener('abort', () => { sawAbortAfterResolve = true; });
return Promise.resolve(new Response('fast'));
});
const res = await fetchWithTimeout('https://example.test', { timeoutMs: 20 });
assert.equal(await res.text(), 'fast');
await new Promise((r) => setTimeout(r, 40));
assert.equal(sawAbortAfterResolve, false, 'timer was cleared after fetch resolved');
});
it('propagates errors from fetch itself without waiting for the timeout', async () => {
installFetch(() => Promise.reject(new Error('network down')));
await assert.rejects(
() => fetchWithTimeout('https://example.test', { timeoutMs: 1000 }),
(err) => err.message === 'network down',
);
});
it('aborts immediately if the caller-provided signal is already aborted', async () => {
let receivedAbort = false;
installFetch((_input, init) => {
if (init.signal.aborted) receivedAbort = true;
const err = new Error('aborted');
err.name = 'AbortError';
return Promise.reject(err);
});
const outer = new AbortController();
outer.abort();
await assert.rejects(
() => fetchWithTimeout('https://example.test', { signal: outer.signal, timeoutMs: 1000 }),
(err) => err.name === 'AbortError',
);
assert.equal(receivedAbort, true, 'internal signal inherited the pre-aborted state');
});
it('propagates caller-signal abort to the fetch', async () => {
let sawAbort = false;
installFetch((_input, init) => {
const { promise, reject } = deferred();
init.signal.addEventListener('abort', () => {
sawAbort = true;
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
return promise;
});
const outer = new AbortController();
const p = fetchWithTimeout('https://example.test', { signal: outer.signal, timeoutMs: 10000 });
queueMicrotask(() => outer.abort());
await assert.rejects(p, (err) => err.name === 'AbortError');
assert.equal(sawAbort, true);
});
it('uses the 5000ms default timeout when not specified', async () => {
// We can't easily wait 5 seconds in a test, just verify the fetch was
// called with a signal and the default didn't fire synchronously.
let sawSignal = false;
installFetch(async (_input, init) => {
sawSignal = init.signal instanceof AbortSignal && !init.signal.aborted;
return new Response('ok');
});
await fetchWithTimeout('https://example.test');
assert.equal(sawSignal, true);
});
});
describe('fetchUpstream()', () => {
afterEach(() => {
restoreFetch();
});
it('is a thin preset around fetchWithTimeout that still resolves normally', async () => {
installFetch(async () => new Response('upstream-ok'));
const res = await fetchUpstream('https://example.test');
assert.equal(await res.text(), 'upstream-ok');
});
it('still aborts on timeout (inherits fetchWithTimeout behavior)', async () => {
let sawAbort = false;
installFetch((_input, init) => {
const { promise, reject } = deferred();
init.signal.addEventListener('abort', () => {
sawAbort = true;
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
return promise;
});
// Pass a short timeoutMs so the test doesn't actually wait 8s for the default.
await assert.rejects(
() => fetchUpstream('https://example.test', { timeoutMs: 20 }),
(err) => err.name === 'AbortError',
);
assert.equal(sawAbort, true);
});
it('caller-provided init fields override the 8000ms default', async () => {
// Verify that { timeoutMs: 100 } overrides the 8000 preset, proving
// the preset merges rather than forcing a fixed timeout.
let observedSignal = null;
installFetch(async (_input, init) => {
observedSignal = init.signal;
return new Response('ok');
});
await fetchUpstream('https://example.test', { timeoutMs: 100 });
assert.ok(observedSignal instanceof AbortSignal);
});
});
describe('fetchUpstream() default User-Agent', () => {
afterEach(() => {
restoreFetch();
setUpstreamUserAgent(null);
});
it('sends no User-Agent header when none has been registered', async () => {
let observedHeaders;
installFetch(async (_input, init) => {
observedHeaders = init.headers;
return new Response('ok');
});
await fetchUpstream('https://example.test');
assert.equal(observedHeaders, undefined);
});
it('injects the registered User-Agent when the caller sets no headers', async () => {
setUpstreamUserAgent('MyIP/v0.0.0/https://example.test');
let observedHeaders;
installFetch(async (_input, init) => {
observedHeaders = init.headers;
return new Response('ok');
});
await fetchUpstream('https://example.test');
assert.deepEqual(observedHeaders, { 'User-Agent': 'MyIP/v0.0.0/https://example.test' });
});
it('merges the User-Agent into caller headers without clobbering them', async () => {
setUpstreamUserAgent('MyIP/v0.0.0/https://example.test');
let observedHeaders;
installFetch(async (_input, init) => {
observedHeaders = init.headers;
return new Response('ok');
});
await fetchUpstream('https://example.test', { headers: { Accept: 'application/json' } });
assert.deepEqual(observedHeaders, {
Accept: 'application/json',
'User-Agent': 'MyIP/v0.0.0/https://example.test',
});
});
it('never overrides or duplicates a caller-supplied User-Agent, any casing', async () => {
// Pass-through handlers forward the visitor's headers, whose
// user-agent key arrives lowercase from Node — that one must win.
setUpstreamUserAgent('MyIP/v0.0.0/https://example.test');
let observedHeaders;
installFetch(async (_input, init) => {
observedHeaders = init.headers;
return new Response('ok');
});
await fetchUpstream('https://example.test', { headers: { 'user-agent': 'Mozilla/5.0' } });
assert.deepEqual(observedHeaders, { 'user-agent': 'Mozilla/5.0' });
});
it('respects a User-Agent carried in a Headers instance', async () => {
setUpstreamUserAgent('MyIP/v0.0.0/https://example.test');
let observedHeaders;
installFetch(async (_input, init) => {
observedHeaders = init.headers;
return new Response('ok');
});
const headers = new Headers({ 'User-Agent': 'Mozilla/5.0' });
await fetchUpstream('https://example.test', { headers });
assert.equal(observedHeaders, headers);
});
});