Skip to content

Commit adeaa83

Browse files
test: add coverage for retries, redirects, client helpers, and header controls
1 parent 1734508 commit adeaa83

6 files changed

Lines changed: 385 additions & 1 deletion

File tree

src/test/cookies-redirects.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,30 @@ describe('cookies and redirects', () => {
180180
'redirect error mode should throw on first redirect response'
181181
);
182182
});
183+
184+
test('should reject when maxRedirects is exceeded', async () => {
185+
await assert.rejects(
186+
async () => {
187+
await fetch(`${getBaseUrl()}/redirect/chain?count=2`, {
188+
maxRedirects: 1,
189+
});
190+
},
191+
(error: unknown) =>
192+
error instanceof Error &&
193+
'code' in (error as object) &&
194+
(error as { code?: unknown }).code === 'ERR_TOO_MANY_REDIRECTS'
195+
);
196+
});
197+
198+
test('should reject redirect loops', async () => {
199+
await assert.rejects(
200+
async () => {
201+
await fetch(`${getBaseUrl()}/redirect/loop-a`);
202+
},
203+
(error: unknown) =>
204+
error instanceof Error &&
205+
'code' in (error as object) &&
206+
(error as { code?: unknown }).code === 'ERR_REDIRECT_LOOP'
207+
);
208+
});
183209
});

src/test/helpers/local-server.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ export function setupLocalTestServer() {
6969

7070
wsServer.on('connection', (socket: WsPeer, request: IncomingMessage) => {
7171
const cookie = readCookieHeader(request);
72+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
7273

7374
socket.send(
7475
JSON.stringify({
7576
kind: 'connected',
7677
cookie,
7778
protocol: socket.protocol,
79+
url: url.pathname + url.search,
7880
rawHeaders: request.rawHeaders,
7981
})
8082
);
@@ -112,6 +114,29 @@ export function setupLocalTestServer() {
112114
return;
113115
}
114116

117+
if (url.pathname === '/retry/timeout') {
118+
const key = url.searchParams.get('key') ?? 'default-timeout';
119+
const failCount = Number(url.searchParams.get('failCount') ?? '0');
120+
const delayMs = Number(url.searchParams.get('delayMs') ?? '100');
121+
const count = (retryAttempts.get(key) ?? 0) + 1;
122+
123+
retryAttempts.set(key, count);
124+
125+
if (count <= failCount) {
126+
setTimeout(() => {
127+
if (!response.writableEnded) {
128+
sendJson(response, 200, { attempt: count, timedOut: true });
129+
}
130+
}, delayMs);
131+
132+
return;
133+
}
134+
135+
sendJson(response, 200, { attempt: count, timedOut: false });
136+
137+
return;
138+
}
139+
115140
if (url.pathname === '/timings/delay') {
116141
const delayMs = Number(url.searchParams.get('ms') ?? '50');
117142

@@ -197,6 +222,14 @@ export function setupLocalTestServer() {
197222
return;
198223
}
199224

225+
if (url.pathname.startsWith('/status/')) {
226+
const status = Number(url.pathname.slice('/status/'.length));
227+
228+
sendJson(response, status, { status });
229+
230+
return;
231+
}
232+
200233
if (url.pathname === '/redirect/start') {
201234
response.writeHead(302, {
202235
location: '/redirect/final',
@@ -216,6 +249,44 @@ export function setupLocalTestServer() {
216249
return;
217250
}
218251

252+
if (url.pathname === '/redirect/chain') {
253+
const count = Number(url.searchParams.get('count') ?? '0');
254+
255+
if (count > 0) {
256+
response.writeHead(302, {
257+
location: `/redirect/chain?count=${count - 1}`,
258+
});
259+
response.end();
260+
261+
return;
262+
}
263+
264+
response.writeHead(302, {
265+
location: '/redirect/final',
266+
});
267+
response.end();
268+
269+
return;
270+
}
271+
272+
if (url.pathname === '/redirect/loop-a') {
273+
response.writeHead(302, {
274+
location: '/redirect/loop-b',
275+
});
276+
response.end();
277+
278+
return;
279+
}
280+
281+
if (url.pathname === '/redirect/loop-b') {
282+
response.writeHead(302, {
283+
location: '/redirect/loop-a',
284+
});
285+
response.end();
286+
287+
return;
288+
}
289+
219290
if (url.pathname === '/redirect/final') {
220291
sendJson(response, 200, {
221292
method: request.method,

src/test/hooks-retries.spec.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ describe('hooks and retries', () => {
3737
);
3838
});
3939

40+
test('should allow beforeRequest to short-circuit with a synthetic response', async () => {
41+
const response = await fetch(`${getBaseUrl()}/headers/raw`, {
42+
hooks: {
43+
beforeRequest: [
44+
() =>
45+
new WreqResponse(JSON.stringify({ shortCircuited: true }), {
46+
status: 204,
47+
headers: { 'content-type': 'application/json' },
48+
url: 'https://local/short-circuit',
49+
}),
50+
],
51+
},
52+
});
53+
54+
assert.strictEqual(response.status, 204);
55+
assert.deepStrictEqual(await response.json(), { shortCircuited: true });
56+
assert.strictEqual(response.url, 'https://local/short-circuit');
57+
assert.deepStrictEqual(response.wreq.timings, {
58+
startTime: response.wreq.timings?.startTime,
59+
responseStart: response.wreq.timings?.startTime,
60+
wait: 0,
61+
endTime: response.wreq.timings?.startTime,
62+
total: 0,
63+
});
64+
});
65+
4066
test('should allow afterResponse to replace the response', async () => {
4167
const response = await fetch('https://httpbin.org/status/201', {
4268
browser: 'chrome_137',
@@ -115,6 +141,84 @@ describe('hooks and retries', () => {
115141
assert.strictEqual(body.retried, true, 'server should observe retries');
116142
});
117143

144+
test('should allow shouldRetry to veto a retryable response', async () => {
145+
retryAttempts.set('retry-veto', 0);
146+
147+
const response = await fetch(`${getBaseUrl()}/retry?key=retry-veto&failCount=1`, {
148+
retry: {
149+
limit: 1,
150+
statusCodes: [503],
151+
shouldRetry: () => false,
152+
},
153+
});
154+
155+
assert.strictEqual(response.status, 503);
156+
assert.deepStrictEqual(await response.json(), {
157+
attempt: 1,
158+
retried: false,
159+
});
160+
});
161+
162+
test('should stop retrying when the retry limit is exhausted', async () => {
163+
retryAttempts.set('retry-limit', 0);
164+
165+
const response = await fetch(`${getBaseUrl()}/retry?key=retry-limit&failCount=2`, {
166+
retry: {
167+
limit: 1,
168+
statusCodes: [503],
169+
backoff: () => 0,
170+
},
171+
});
172+
173+
assert.strictEqual(response.status, 503);
174+
assert.deepStrictEqual(await response.json(), {
175+
attempt: 2,
176+
retried: false,
177+
});
178+
});
179+
180+
test('should not retry methods outside the configured retry method list', async () => {
181+
retryAttempts.set('retry-methods', 0);
182+
183+
const response = await fetch(`${getBaseUrl()}/retry?key=retry-methods&failCount=1`, {
184+
method: 'POST',
185+
body: 'payload',
186+
retry: {
187+
limit: 2,
188+
methods: ['GET'],
189+
statusCodes: [503],
190+
},
191+
});
192+
193+
assert.strictEqual(response.status, 503);
194+
assert.deepStrictEqual(await response.json(), {
195+
attempt: 1,
196+
retried: false,
197+
});
198+
});
199+
200+
test('should retry timeout errors when their error code is configured', async () => {
201+
retryAttempts.set('retry-timeout', 0);
202+
203+
const response = await fetch(
204+
`${getBaseUrl()}/retry/timeout?key=retry-timeout&failCount=1&delayMs=100`,
205+
{
206+
timeout: 25,
207+
retry: {
208+
limit: 1,
209+
errorCodes: ['ERR_TIMEOUT'],
210+
backoff: () => 0,
211+
},
212+
}
213+
);
214+
215+
assert.strictEqual(response.status, 200);
216+
assert.deepStrictEqual(await response.json(), {
217+
attempt: 2,
218+
timedOut: false,
219+
});
220+
});
221+
118222
test('should expose response timings and onStats callback data', async () => {
119223
let capturedStats:
120224
| {

src/test/http-client.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,98 @@ describe('http client', () => {
170170
);
171171
});
172172

173+
test('should allow validateStatus to accept a custom non-2xx response', async () => {
174+
const response = await fetch(`${getBaseUrl()}/status/418`, {
175+
throwHttpErrors: true,
176+
validateStatus: (status) => status === 418,
177+
});
178+
179+
assert.strictEqual(response.status, 418);
180+
assert.deepStrictEqual(await response.json(), { status: 418 });
181+
});
182+
183+
test('should reject responses when validateStatus returns false', async () => {
184+
await assert.rejects(
185+
async () => {
186+
await fetch(`${getBaseUrl()}/status/204`, {
187+
throwHttpErrors: false,
188+
validateStatus: () => false,
189+
});
190+
},
191+
(error: unknown) => error instanceof Error && error.name === 'HTTPError'
192+
);
193+
});
194+
195+
test('should support client.post helper', async () => {
196+
const client = createClient({
197+
baseURL: getBaseUrl(),
198+
});
199+
200+
const response = await client.post('/body/echo', JSON.stringify({ created: true }), {
201+
headers: {
202+
'content-type': 'application/json',
203+
},
204+
});
205+
const body = await response.json<{ method: string; body: string }>();
206+
207+
assert.strictEqual(body.method, 'POST');
208+
assert.strictEqual(body.body, JSON.stringify({ created: true }));
209+
});
210+
211+
test('should merge defaults through client.extend', async () => {
212+
let observedState: Record<string, unknown> | undefined;
213+
214+
const baseClient = createClient({
215+
baseURL: getBaseUrl(),
216+
headers: {
217+
'x-base': 'one',
218+
},
219+
query: {
220+
base: '1',
221+
},
222+
context: {
223+
fromBase: true,
224+
},
225+
hooks: {
226+
beforeRequest: [
227+
({ request, state }) => {
228+
observedState = { ...state };
229+
request.headers.set(
230+
'x-state',
231+
`${String(state.fromBase)}:${String(state.fromOverride)}`
232+
);
233+
},
234+
],
235+
},
236+
});
237+
238+
const client = baseClient.extend({
239+
headers: {
240+
'x-extended': 'two',
241+
},
242+
query: {
243+
extended: '2',
244+
},
245+
context: {
246+
fromOverride: true,
247+
},
248+
});
249+
250+
const response = await client.get('/headers/raw');
251+
const body = await response.json<{ headers: Record<string, string> }>();
252+
const requestUrl = new URL(response.url);
253+
254+
assert.strictEqual(body.headers['x-base'], 'one');
255+
assert.strictEqual(body.headers['x-extended'], 'two');
256+
assert.strictEqual(body.headers['x-state'], 'true:true');
257+
assert.strictEqual(requestUrl.searchParams.get('base'), '1');
258+
assert.strictEqual(requestUrl.searchParams.get('extended'), '2');
259+
assert.deepStrictEqual(observedState, {
260+
fromBase: true,
261+
fromOverride: true,
262+
});
263+
});
264+
173265
test('should preserve ordered header tuples and original header names', async () => {
174266
const response = await fetch(`${getBaseUrl()}/headers/raw`, {
175267
browser: 'chrome_137',

0 commit comments

Comments
 (0)