Skip to content

Commit 87e7884

Browse files
committed
test(clients): cover URL-after-interceptor and error-chain fixes
Adds regression tests that fail on pre-fix source and pass on post-fix source: - client-next: four tests asserting that request interceptor mutations to `opts.baseUrl`, `opts.url`, `opts.path`, and `opts.query` are reflected in the final fetch URL — previously the URL was built before interceptors ran, so these mutations were silently dropped. - client-next / client-fetch / client-ky: error-chain composition tests that install two error interceptors and assert the second sees the first's output (not the original error). Previously `finalError` was reassigned but the loop kept passing the untransformed `error` to each interceptor. client-fetch gets two variants (fetch-exception path and response-error path) since the bug existed in both interceptor loops.
1 parent f5eb909 commit 87e7884

3 files changed

Lines changed: 269 additions & 0 deletions

File tree

packages/openapi-ts/src/plugins/@hey-api/client-fetch/__tests__/client.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,86 @@ describe('error interceptor for fetch exceptions', () => {
538538
expect(result.error).toBe(abortError);
539539
});
540540
});
541+
542+
describe('error interceptor chain composition', () => {
543+
// Regression tests for a bug where each error interceptor received the
544+
// original `error` rather than the previous interceptor's output, silently
545+
// dropping transformations earlier in the chain.
546+
547+
it('threads finalError through the fetch-exception error path', async () => {
548+
const client = createClient({ baseUrl: 'https://example.com' });
549+
550+
const networkError = new TypeError('Failed to fetch');
551+
const mockFetch: MockFetch = vi.fn().mockRejectedValue(networkError);
552+
553+
const firstInterceptor = vi.fn(() => ({ stage: 'first' }));
554+
const secondInterceptor = vi.fn((error: unknown) => ({
555+
...(error as object),
556+
stage: 'second',
557+
}));
558+
559+
const firstId = client.interceptors.error.use(firstInterceptor);
560+
const secondId = client.interceptors.error.use(secondInterceptor);
561+
562+
const result = await client.get({ fetch: mockFetch, url: '/test' });
563+
564+
// The second interceptor must receive the first interceptor's output, not
565+
// the original TypeError.
566+
expect(secondInterceptor).toHaveBeenCalledWith(
567+
{ stage: 'first' },
568+
undefined,
569+
expect.any(Request),
570+
expect.any(Object),
571+
);
572+
573+
// The returned error should carry transformations from both interceptors.
574+
expect(result.error).toEqual({ stage: 'second' });
575+
576+
client.interceptors.error.eject(firstId);
577+
client.interceptors.error.eject(secondId);
578+
});
579+
580+
it('threads finalError through the response-error path', async () => {
581+
const client = createClient({ baseUrl: 'https://example.com' });
582+
583+
const errorResponse = new Response(JSON.stringify({ message: 'original' }), {
584+
headers: { 'Content-Type': 'application/json' },
585+
status: 500,
586+
});
587+
588+
const mockFetch: MockFetch = vi.fn().mockResolvedValue(errorResponse);
589+
590+
const firstInterceptor = vi.fn((error: unknown) => ({
591+
...(error as object),
592+
first: true,
593+
}));
594+
const secondInterceptor = vi.fn((error: unknown) => ({
595+
...(error as object),
596+
second: true,
597+
}));
598+
599+
const firstId = client.interceptors.error.use(firstInterceptor);
600+
const secondId = client.interceptors.error.use(secondInterceptor);
601+
602+
const result = await client.get({ fetch: mockFetch, url: '/test' });
603+
604+
// The second interceptor must see the first interceptor's output
605+
// (including `first: true`), not the original payload.
606+
expect(secondInterceptor).toHaveBeenCalledWith(
607+
expect.objectContaining({ first: true, message: 'original' }),
608+
expect.any(Response),
609+
expect.any(Request),
610+
expect.any(Object),
611+
);
612+
613+
// The returned error should carry transformations from both interceptors.
614+
expect(result.error).toEqual({
615+
first: true,
616+
message: 'original',
617+
second: true,
618+
});
619+
620+
client.interceptors.error.eject(firstId);
621+
client.interceptors.error.eject(secondId);
622+
});
623+
});

packages/openapi-ts/src/plugins/@hey-api/client-ky/__tests__/client.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,61 @@ describe('error interceptor', () => {
581581

582582
client.interceptors.error.eject(interceptorId);
583583
});
584+
585+
// Regression test for a bug where each error interceptor received the
586+
// original `error` rather than the previous interceptor's output, silently
587+
// dropping transformations earlier in the chain.
588+
it('passes each interceptor the previous interceptor output, not the original error', async () => {
589+
const client = createClient({ baseUrl: 'https://example.com' });
590+
591+
const errorResponse = new Response(JSON.stringify({ message: 'original' }), {
592+
headers: { 'Content-Type': 'application/json' },
593+
status: 404,
594+
});
595+
596+
const mockKy = vi.fn().mockRejectedValue(
597+
new HTTPError(errorResponse, new Request('https://example.com/test'), {
598+
method: 'GET',
599+
} as any),
600+
);
601+
602+
const firstInterceptor = vi.fn((error: unknown) => ({
603+
...(error as object),
604+
first: true,
605+
}));
606+
const secondInterceptor = vi.fn((error: unknown) => ({
607+
...(error as object),
608+
second: true,
609+
}));
610+
611+
const firstId = client.interceptors.error.use(firstInterceptor);
612+
const secondId = client.interceptors.error.use(secondInterceptor);
613+
614+
const result = await client.get({
615+
ky: mockKy as Partial<KyInstance> as KyInstance,
616+
throwOnError: false,
617+
url: '/test',
618+
});
619+
620+
// The second interceptor must see the first interceptor's output
621+
// (including `first: true`), not the original payload.
622+
expect(secondInterceptor).toHaveBeenCalledWith(
623+
expect.objectContaining({ first: true, message: 'original' }),
624+
expect.any(Response),
625+
expect.any(Request),
626+
expect.any(Object),
627+
);
628+
629+
// The returned error should carry transformations from both interceptors.
630+
expect(result.error).toEqual({
631+
first: true,
632+
message: 'original',
633+
second: true,
634+
});
635+
636+
client.interceptors.error.eject(firstId);
637+
client.interceptors.error.eject(secondId);
638+
});
584639
});
585640

586641
describe('retry configuration', () => {

packages/openapi-ts/src/plugins/@hey-api/client-next/__tests__/client.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,134 @@ describe('request interceptor', () => {
286286
},
287287
);
288288
});
289+
290+
describe('request interceptor URL mutation', () => {
291+
// Regression tests for a bug where the final URL was computed before request
292+
// interceptors ran, causing interceptor mutations to `opts.baseUrl`,
293+
// `opts.url`, `opts.path`, and `opts.query` to be silently ignored.
294+
const buildOkResponse = () =>
295+
new Response(JSON.stringify({ success: true }), {
296+
headers: { 'Content-Type': 'application/json' },
297+
status: 200,
298+
});
299+
300+
it('honors interceptor mutations to opts.baseUrl in the fetched URL', async () => {
301+
const client = createClient({ baseUrl: 'https://example.com' });
302+
const mockFetch: MockFetch = vi.fn().mockResolvedValue(buildOkResponse());
303+
304+
const interceptorId = client.interceptors.request.use((options: ResolvedRequestOptions) => {
305+
options.baseUrl = 'https://rerouted.com';
306+
});
307+
308+
await client.get({ fetch: mockFetch, url: '/test' });
309+
310+
expect(mockFetch).toHaveBeenCalledExactlyOnceWith(
311+
'https://rerouted.com/test',
312+
expect.any(Object),
313+
);
314+
315+
client.interceptors.request.eject(interceptorId);
316+
});
317+
318+
it('honors interceptor mutations to opts.url in the fetched URL', async () => {
319+
const client = createClient({ baseUrl: 'https://example.com' });
320+
const mockFetch: MockFetch = vi.fn().mockResolvedValue(buildOkResponse());
321+
322+
const interceptorId = client.interceptors.request.use((options: ResolvedRequestOptions) => {
323+
options.url = '/rewritten';
324+
});
325+
326+
await client.get({ fetch: mockFetch, url: '/original' });
327+
328+
expect(mockFetch).toHaveBeenCalledExactlyOnceWith(
329+
'https://example.com/rewritten',
330+
expect.any(Object),
331+
);
332+
333+
client.interceptors.request.eject(interceptorId);
334+
});
335+
336+
it('honors interceptor mutations to opts.path in the fetched URL', async () => {
337+
const client = createClient({ baseUrl: 'https://example.com' });
338+
const mockFetch: MockFetch = vi.fn().mockResolvedValue(buildOkResponse());
339+
340+
const interceptorId = client.interceptors.request.use((options: ResolvedRequestOptions) => {
341+
options.path = { id: 42 };
342+
});
343+
344+
await client.get({ fetch: mockFetch, url: '/items/{id}' });
345+
346+
expect(mockFetch).toHaveBeenCalledExactlyOnceWith(
347+
'https://example.com/items/42',
348+
expect.any(Object),
349+
);
350+
351+
client.interceptors.request.eject(interceptorId);
352+
});
353+
354+
it('honors interceptor mutations to opts.query in the fetched URL', async () => {
355+
const client = createClient({ baseUrl: 'https://example.com' });
356+
const mockFetch: MockFetch = vi.fn().mockResolvedValue(buildOkResponse());
357+
358+
const interceptorId = client.interceptors.request.use((options: ResolvedRequestOptions) => {
359+
options.query = { bar: 'baz' };
360+
});
361+
362+
await client.get({ fetch: mockFetch, url: '/items' });
363+
364+
expect(mockFetch).toHaveBeenCalledExactlyOnceWith(
365+
'https://example.com/items?bar=baz',
366+
expect.any(Object),
367+
);
368+
369+
client.interceptors.request.eject(interceptorId);
370+
});
371+
});
372+
373+
describe('error interceptor chain composition', () => {
374+
// Regression test for a bug where each error interceptor received the
375+
// original `error` rather than the previous interceptor's output, silently
376+
// dropping transformations earlier in the chain.
377+
it('passes each interceptor the previous interceptor output, not the original error', async () => {
378+
const client = createClient({ baseUrl: 'https://example.com' });
379+
380+
const errorResponse = new Response(JSON.stringify({ message: 'original' }), {
381+
headers: { 'Content-Type': 'application/json' },
382+
status: 500,
383+
});
384+
385+
const mockFetch: MockFetch = vi.fn().mockResolvedValue(errorResponse);
386+
387+
const firstInterceptor = vi.fn((error: unknown) => ({
388+
...(error as object),
389+
first: true,
390+
}));
391+
const secondInterceptor = vi.fn((error: unknown) => ({
392+
...(error as object),
393+
second: true,
394+
}));
395+
396+
const firstId = client.interceptors.error.use(firstInterceptor);
397+
const secondId = client.interceptors.error.use(secondInterceptor);
398+
399+
const result = await client.get({ fetch: mockFetch, url: '/test' });
400+
401+
// The second interceptor must see the first interceptor's output
402+
// (including `first: true`), not the original payload.
403+
expect(secondInterceptor).toHaveBeenCalledWith(
404+
expect.objectContaining({ first: true, message: 'original' }),
405+
expect.any(Response),
406+
expect.any(Object),
407+
);
408+
409+
// The returned error should carry transformations from both interceptors.
410+
expect(result.error).toEqual({
411+
first: true,
412+
message: 'original',
413+
second: true,
414+
});
415+
416+
client.interceptors.error.eject(firstId);
417+
client.interceptors.error.eject(secondId);
418+
});
419+
});

0 commit comments

Comments
 (0)