@r0hitsharma/http-client-react 0.12.0-rohit-fork-ci.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,644 @@
1
+ import { createApiClient } from '@r0hitsharma/http-client-core';
2
+ import { MutationObserver, QueryClient } from '@tanstack/react-query';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { HttpRequestError, isHttpRequestError } from './errors.js';
6
+ import type { QueryApiMiddleware } from './middleware.js';
7
+ import { createQueryApi } from './query-api.js';
8
+ import {
9
+ createFetchStub,
10
+ emptyFaultResponse,
11
+ emptyResponse,
12
+ headResponse,
13
+ jsonResponse,
14
+ type TestPaths,
15
+ type TestTag,
16
+ type User,
17
+ } from './test-fixtures.js';
18
+ import { createZodResponseMiddleware } from './zod-response.js';
19
+
20
+ const ada: User = { id: 'u1', name: 'Ada' };
21
+
22
+ // Several specs below spy on `console.warn`; leaving one installed would
23
+ // swallow warnings from every spec after it.
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ /** Retries off, so a failure assertion does not wait out the backoff. */
29
+ function createTestQueryClient(): QueryClient {
30
+ return new QueryClient({
31
+ defaultOptions: {
32
+ queries: { retry: false },
33
+ mutations: { retry: false },
34
+ },
35
+ });
36
+ }
37
+
38
+ function setup(
39
+ options: {
40
+ middleware?: readonly QueryApiMiddleware[];
41
+ tags?: readonly TestTag[];
42
+ } = {},
43
+ ) {
44
+ const stub = createFetchStub({
45
+ 'GET /users': () => jsonResponse([ada]),
46
+ 'GET /users/u1': () => jsonResponse(ada),
47
+ 'GET /users/missing': () => jsonResponse({ message: 'no such user' }, 404),
48
+ 'GET /users/gone': () => emptyFaultResponse(),
49
+ 'HEAD /users': () => headResponse(),
50
+ 'POST /users': async (request) => {
51
+ const body = (await request.json()) as { name: string };
52
+ return jsonResponse({ id: 'u2', name: body.name }, 201);
53
+ },
54
+ 'DELETE /users/u1': () => emptyResponse(),
55
+ });
56
+
57
+ const client = createApiClient<TestPaths>('https://api.test', {
58
+ fetch: stub.fetch,
59
+ });
60
+
61
+ return {
62
+ stub,
63
+ queryClient: createTestQueryClient(),
64
+ api: createQueryApi(client, {
65
+ tags: options.tags ?? (['users', 'user'] as const),
66
+ middleware: options.middleware,
67
+ }),
68
+ };
69
+ }
70
+
71
+ describe('queryOptions', () => {
72
+ it('fetches through the typed client and derives the query key', async () => {
73
+ const { api, queryClient, stub } = setup();
74
+
75
+ const options = api.queryOptions('get', '/users', {
76
+ params: { query: { limit: 10 } },
77
+ });
78
+
79
+ await expect(queryClient.fetchQuery(options)).resolves.toEqual([ada]);
80
+ expect(options.queryKey).toEqual([
81
+ 'get',
82
+ '/users',
83
+ { query: { limit: 10 } },
84
+ ]);
85
+ expect(stub.requests[0]?.url).toBe('/users?limit=10');
86
+ });
87
+
88
+ it('serializes path params into the request URL', async () => {
89
+ const { api, queryClient, stub } = setup();
90
+
91
+ await queryClient.fetchQuery(
92
+ api.queryOptions('get', '/users/{id}', {
93
+ params: { path: { id: 'u1' } },
94
+ }),
95
+ );
96
+
97
+ expect(stub.requests[0]?.url).toBe('/users/u1');
98
+ });
99
+
100
+ it('shares one cache entry across param orderings', async () => {
101
+ const { api, queryClient, stub } = setup();
102
+
103
+ // `staleTime` so the second fetch is a cache read: identical requests
104
+ // written in a different key order have to land on the same entry.
105
+ await queryClient.fetchQuery(
106
+ api.queryOptions(
107
+ 'get',
108
+ '/users',
109
+ { params: { query: { limit: 10, search: 'ada' } } },
110
+ { staleTime: Infinity },
111
+ ),
112
+ );
113
+ await queryClient.fetchQuery(
114
+ api.queryOptions(
115
+ 'get',
116
+ '/users',
117
+ { params: { query: { search: 'ada', limit: 10 } } },
118
+ { staleTime: Infinity },
119
+ ),
120
+ );
121
+
122
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
123
+ expect(stub.requests).toHaveLength(1);
124
+ });
125
+
126
+ it('rejects with a typed error carrying status and parsed body', async () => {
127
+ const { api, queryClient } = setup();
128
+
129
+ const error = await queryClient
130
+ .fetchQuery(
131
+ api.queryOptions('get', '/users/{id}', {
132
+ params: { path: { id: 'missing' } },
133
+ }),
134
+ )
135
+ .catch((thrown: unknown) => thrown);
136
+
137
+ expect(isHttpRequestError(error)).toBe(true);
138
+ if (!isHttpRequestError(error)) return;
139
+
140
+ expect(error).toBeInstanceOf(HttpRequestError);
141
+ expect(error.status).toBe(404);
142
+ expect(error.body).toEqual({ message: 'no such user' });
143
+ expect(error.method).toBe('get');
144
+ expect(error.path).toBe('/users/{id}');
145
+ expect(error.message).toContain('GET /users/{id}');
146
+ });
147
+
148
+ it('leaves body undefined when the failing response carries none', async () => {
149
+ const { api, queryClient } = setup();
150
+
151
+ const error = await queryClient
152
+ .fetchQuery(
153
+ api.queryOptions('get', '/users/{id}', {
154
+ params: { path: { id: 'gone' } },
155
+ }),
156
+ )
157
+ .catch((thrown: unknown) => thrown);
158
+
159
+ // Not every failure has a parseable body — which is why `body` is typed
160
+ // `TBody | undefined` rather than `TBody`.
161
+ expect(isHttpRequestError(error)).toBe(true);
162
+ if (!isHttpRequestError(error)) return;
163
+ expect(error.status).toBe(500);
164
+ expect(error.body).toBeUndefined();
165
+ });
166
+
167
+ it('resolves a HEAD to null even when Content-Length is non-zero', async () => {
168
+ const { api, queryClient, stub } = setup();
169
+
170
+ // `openapi-fetch` never parses a body for HEAD, so `data` is `undefined`
171
+ // however the response is framed. Without the HEAD branch this rejects
172
+ // with react-query's "Query data cannot be undefined".
173
+ await expect(
174
+ queryClient.fetchQuery(api.queryOptions('head', '/users')),
175
+ ).resolves.toBeNull();
176
+ expect(stub.requests[0]?.method).toBe('HEAD');
177
+ });
178
+
179
+ it("threads react-query's abort signal into the request init", async () => {
180
+ let seen: unknown;
181
+ const { api, queryClient } = setup({
182
+ middleware: [
183
+ async (ctx, next) => {
184
+ seen = ctx.init?.signal;
185
+ return await next();
186
+ },
187
+ ],
188
+ });
189
+
190
+ await queryClient.fetchQuery(api.queryOptions('get', '/users'));
191
+
192
+ expect(seen).toBeInstanceOf(AbortSignal);
193
+ });
194
+ });
195
+
196
+ describe('mutationOptions', () => {
197
+ it('sends the mutation variables as the request init', async () => {
198
+ const { api, queryClient, stub } = setup();
199
+
200
+ const observer = new MutationObserver(
201
+ queryClient,
202
+ api.mutationOptions('post', '/users'),
203
+ );
204
+
205
+ await expect(observer.mutate({ body: { name: 'Grace' } })).resolves.toEqual(
206
+ {
207
+ id: 'u2',
208
+ name: 'Grace',
209
+ },
210
+ );
211
+ expect(stub.requests[0]?.method).toBe('POST');
212
+ expect(stub.requests[0]?.body).toBe('{"name":"Grace"}');
213
+ });
214
+
215
+ it('resolves to null for a 204 with no body', async () => {
216
+ const { api, queryClient } = setup();
217
+
218
+ const observer = new MutationObserver(
219
+ queryClient,
220
+ api.mutationOptions('delete', '/users/{id}'),
221
+ );
222
+
223
+ await expect(
224
+ observer.mutate({ params: { path: { id: 'u1' } } }),
225
+ ).resolves.toBeNull();
226
+ });
227
+
228
+ it('invalidates the queries registered under an invalidated tag', async () => {
229
+ const { api, queryClient } = setup();
230
+
231
+ const listOptions = api.queryOptions('get', '/users', undefined, {
232
+ tags: ['users'],
233
+ });
234
+ const detailOptions = api.queryOptions(
235
+ 'get',
236
+ '/users/{id}',
237
+ { params: { path: { id: 'u1' } } },
238
+ { tags: ['user'] },
239
+ );
240
+
241
+ await queryClient.fetchQuery(listOptions);
242
+ await queryClient.fetchQuery(detailOptions);
243
+
244
+ const observer = new MutationObserver(
245
+ queryClient,
246
+ api.mutationOptions('post', '/users', { invalidates: ['users'] }),
247
+ );
248
+ await observer.mutate({ body: { name: 'Grace' } });
249
+
250
+ expect(queryClient.getQueryState(listOptions.queryKey)?.isInvalidated).toBe(
251
+ true,
252
+ );
253
+ expect(
254
+ queryClient.getQueryState(detailOptions.queryKey)?.isInvalidated,
255
+ ).toBe(false);
256
+ });
257
+
258
+ it('invalidates every cached variant of a tagged endpoint', async () => {
259
+ const { api, queryClient } = setup();
260
+
261
+ const first = api.queryOptions(
262
+ 'get',
263
+ '/users',
264
+ { params: { query: { limit: 1 } } },
265
+ { tags: ['users'] },
266
+ );
267
+ const second = api.queryOptions(
268
+ 'get',
269
+ '/users',
270
+ { params: { query: { limit: 2 } } },
271
+ { tags: ['users'] },
272
+ );
273
+
274
+ await queryClient.fetchQuery(first);
275
+ await queryClient.fetchQuery(second);
276
+
277
+ const observer = new MutationObserver(
278
+ queryClient,
279
+ api.mutationOptions('post', '/users', { invalidates: ['users'] }),
280
+ );
281
+ await observer.mutate({ body: { name: 'Grace' } });
282
+
283
+ expect(queryClient.getQueryState(first.queryKey)?.isInvalidated).toBe(true);
284
+ expect(queryClient.getQueryState(second.queryKey)?.isInvalidated).toBe(
285
+ true,
286
+ );
287
+ });
288
+
289
+ it('resolves tags produced from the mutation result', async () => {
290
+ const { api, queryClient } = setup();
291
+
292
+ const detailOptions = api.queryOptions(
293
+ 'get',
294
+ '/users/{id}',
295
+ { params: { path: { id: 'u1' } } },
296
+ { tags: ['user'] },
297
+ );
298
+ await queryClient.fetchQuery(detailOptions);
299
+
300
+ const observer = new MutationObserver(
301
+ queryClient,
302
+ api.mutationOptions('post', '/users', {
303
+ invalidates: [(created) => (created.name === 'Grace' ? 'user' : [])],
304
+ }),
305
+ );
306
+ await observer.mutate({ body: { name: 'Grace' } });
307
+
308
+ expect(
309
+ queryClient.getQueryState(detailOptions.queryKey)?.isInvalidated,
310
+ ).toBe(true);
311
+ });
312
+
313
+ it('still calls a caller-supplied onSuccess', async () => {
314
+ const { api, queryClient } = setup();
315
+ const onSuccess = vi.fn();
316
+
317
+ const observer = new MutationObserver(
318
+ queryClient,
319
+ api.mutationOptions('post', '/users', {
320
+ invalidates: ['users'],
321
+ onSuccess,
322
+ }),
323
+ );
324
+ await observer.mutate({ body: { name: 'Grace' } });
325
+
326
+ expect(onSuccess).toHaveBeenCalledTimes(1);
327
+ expect(onSuccess.mock.calls[0]?.[0]).toEqual({ id: 'u2', name: 'Grace' });
328
+ });
329
+
330
+ it('warns and skips an unknown tag a callback derives, without failing the mutation', async () => {
331
+ const { api, queryClient } = setup();
332
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
333
+ const onSuccess = vi.fn();
334
+
335
+ const listOptions = api.queryOptions('get', '/users', undefined, {
336
+ tags: ['users'],
337
+ });
338
+ await queryClient.fetchQuery(listOptions);
339
+
340
+ const observer = new MutationObserver(
341
+ queryClient,
342
+ api.mutationOptions('post', '/users', {
343
+ invalidates: [() => ['users', 'orders' as TestTag]],
344
+ onSuccess,
345
+ }),
346
+ );
347
+
348
+ // The unknown tag must not turn a succeeded mutation into a failed one,
349
+ // and must not cost the caller their own onSuccess.
350
+ await expect(observer.mutate({ body: { name: 'Grace' } })).resolves.toEqual(
351
+ {
352
+ id: 'u2',
353
+ name: 'Grace',
354
+ },
355
+ );
356
+ expect(onSuccess).toHaveBeenCalledTimes(1);
357
+
358
+ // The known tag in the same batch still invalidates.
359
+ expect(queryClient.getQueryState(listOptions.queryKey)?.isInvalidated).toBe(
360
+ true,
361
+ );
362
+
363
+ expect(warn).toHaveBeenCalledTimes(1);
364
+ expect(warn.mock.calls[0]?.[0]).toContain('unknown tag "orders"');
365
+
366
+ // Once per unknown tag, not once per mutation.
367
+ await observer.mutate({ body: { name: 'Grace' } });
368
+ expect(warn).toHaveBeenCalledTimes(1);
369
+ });
370
+
371
+ it('throws on an unknown literal tag when the mutation is declared', () => {
372
+ const { api } = setup();
373
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
374
+
375
+ // A literal is checked where it is written, before anything has run, so it
376
+ // still throws rather than warning — that is what catches the typo.
377
+ expect(() =>
378
+ api.mutationOptions('post', '/users', {
379
+ invalidates: ['orders' as TestTag],
380
+ }),
381
+ ).toThrow(/unknown tag "orders"/);
382
+ expect(warn).not.toHaveBeenCalled();
383
+ });
384
+
385
+ it('rejects with a typed error on a failed mutation', async () => {
386
+ const { api, queryClient } = setup();
387
+
388
+ const observer = new MutationObserver(
389
+ queryClient,
390
+ api.mutationOptions('delete', '/users/{id}'),
391
+ );
392
+
393
+ const error = await observer
394
+ .mutate({ params: { path: { id: 'missing' } } })
395
+ .catch((thrown: unknown) => thrown);
396
+
397
+ // No stub route for DELETE /users/missing — the stub answers 501.
398
+ expect(isHttpRequestError(error)).toBe(true);
399
+ if (isHttpRequestError(error)) expect(error.status).toBe(501);
400
+ });
401
+ });
402
+
403
+ describe('tags', () => {
404
+ it('exposes the endpoints registered under a tag', () => {
405
+ const { api } = setup();
406
+
407
+ api.queryOptions('get', '/users', undefined, { tags: ['users'] });
408
+ api.queryOptions(
409
+ 'get',
410
+ '/users/{id}',
411
+ { params: { path: { id: 'u1' } } },
412
+ { tags: ['users'] },
413
+ );
414
+
415
+ expect(api.taggedEndpoints('users')).toEqual([
416
+ 'get /users',
417
+ 'get /users/{id}',
418
+ ]);
419
+ });
420
+
421
+ it('throws on a tag outside the declared vocabulary', () => {
422
+ const { api } = setup();
423
+
424
+ expect(() =>
425
+ api.queryOptions('get', '/users', undefined, {
426
+ tags: ['orders' as TestTag],
427
+ }),
428
+ ).toThrow(/unknown tag "orders"/);
429
+ });
430
+
431
+ it('matches nothing for a tag no query has registered', async () => {
432
+ const { api, queryClient } = setup();
433
+ await queryClient.fetchQuery(api.queryOptions('get', '/users'));
434
+
435
+ await api.invalidateTags(queryClient, ['users']);
436
+
437
+ expect(
438
+ queryClient.getQueryState(api.queryKey('get', '/users'))?.isInvalidated,
439
+ ).toBe(false);
440
+ });
441
+ });
442
+
443
+ describe('queryKey', () => {
444
+ it('targets a cached query without a hand-built key factory', async () => {
445
+ const { api, queryClient } = setup();
446
+ await queryClient.fetchQuery(
447
+ api.queryOptions('get', '/users/{id}', {
448
+ params: { path: { id: 'u1' } },
449
+ }),
450
+ );
451
+
452
+ expect(
453
+ queryClient.getQueryData(
454
+ api.queryKey('get', '/users/{id}', { params: { path: { id: 'u1' } } }),
455
+ ),
456
+ ).toEqual(ada);
457
+ });
458
+
459
+ it('prefix-matches every variant of an endpoint on [method, path]', async () => {
460
+ const { api, queryClient } = setup();
461
+
462
+ const first = api.queryOptions('get', '/users', {
463
+ params: { query: { limit: 1 } },
464
+ });
465
+ const second = api.queryOptions('get', '/users', {
466
+ params: { query: { limit: 2 } },
467
+ });
468
+ await queryClient.fetchQuery(first);
469
+ await queryClient.fetchQuery(second);
470
+
471
+ await queryClient.invalidateQueries({ queryKey: ['get', '/users'] });
472
+
473
+ expect(queryClient.getQueryState(first.queryKey)?.isInvalidated).toBe(true);
474
+ expect(queryClient.getQueryState(second.queryKey)?.isInvalidated).toBe(
475
+ true,
476
+ );
477
+ });
478
+ });
479
+
480
+ describe('middleware', () => {
481
+ it('runs onion order: instance chain first, per-call chain innermost', async () => {
482
+ const trace: string[] = [];
483
+
484
+ const record =
485
+ (name: string): QueryApiMiddleware =>
486
+ async (ctx, next) => {
487
+ trace.push(`enter:${name}`);
488
+ const data = await next(ctx);
489
+ trace.push(`exit:${name}`);
490
+ return data;
491
+ };
492
+
493
+ const { api, queryClient } = setup({
494
+ middleware: [record('outer'), record('inner')],
495
+ });
496
+
497
+ await queryClient.fetchQuery(
498
+ api.queryOptions('get', '/users', undefined, {
499
+ middleware: [record('call')],
500
+ }),
501
+ );
502
+
503
+ expect(trace).toEqual([
504
+ 'enter:outer',
505
+ 'enter:inner',
506
+ 'enter:call',
507
+ 'exit:call',
508
+ 'exit:inner',
509
+ 'exit:outer',
510
+ ]);
511
+ });
512
+
513
+ it('sees the operation it is wrapping', async () => {
514
+ const seen: unknown[] = [];
515
+ const { api, queryClient } = setup({
516
+ middleware: [
517
+ async (ctx, next) => {
518
+ seen.push({
519
+ method: ctx.method,
520
+ path: ctx.path,
521
+ operationType: ctx.operationType,
522
+ });
523
+ return await next();
524
+ },
525
+ ],
526
+ });
527
+
528
+ await queryClient.fetchQuery(
529
+ api.queryOptions('get', '/users/{id}', {
530
+ params: { path: { id: 'u1' } },
531
+ }),
532
+ );
533
+ await new MutationObserver(
534
+ queryClient,
535
+ api.mutationOptions('post', '/users'),
536
+ ).mutate({ body: { name: 'Grace' } });
537
+
538
+ expect(seen).toEqual([
539
+ { method: 'get', path: '/users/{id}', operationType: 'query' },
540
+ { method: 'post', path: '/users', operationType: 'mutation' },
541
+ ]);
542
+ });
543
+
544
+ it('can rewrite the request on the way in', async () => {
545
+ const { api, queryClient, stub } = setup({
546
+ middleware: [
547
+ (ctx, next) =>
548
+ next({
549
+ ...ctx,
550
+ init: { ...ctx.init, params: { query: { limit: 99 } } },
551
+ }),
552
+ ],
553
+ });
554
+
555
+ await queryClient.fetchQuery(api.queryOptions('get', '/users'));
556
+
557
+ expect(stub.requests[0]?.url).toBe('/users?limit=99');
558
+ });
559
+
560
+ it('can replace the result on the way out', async () => {
561
+ const { api, queryClient } = setup({
562
+ middleware: [async (_ctx, next) => [...((await next()) as User[]), ada]],
563
+ });
564
+
565
+ await expect(
566
+ queryClient.fetchQuery(api.queryOptions('get', '/users')),
567
+ ).resolves.toEqual([ada, ada]);
568
+ });
569
+ });
570
+
571
+ describe('createZodResponseMiddleware', () => {
572
+ const document = {
573
+ components: {
574
+ schemas: {
575
+ User: {
576
+ type: 'object',
577
+ properties: { id: { type: 'string' }, name: { type: 'string' } },
578
+ required: ['id', 'name'],
579
+ },
580
+ },
581
+ },
582
+ };
583
+
584
+ it('passes a conforming body through untouched', async () => {
585
+ const middleware = createZodResponseMiddleware({
586
+ document,
587
+ schemas: { 'get /users/{id}': 'User' },
588
+ });
589
+ const { api, queryClient } = setup({ middleware: [middleware] });
590
+
591
+ const data = await queryClient.fetchQuery(
592
+ api.queryOptions('get', '/users/{id}', {
593
+ params: { path: { id: 'u1' } },
594
+ }),
595
+ );
596
+
597
+ expect(data).toEqual(ada);
598
+ });
599
+
600
+ it('rejects a body that violates its component schema', async () => {
601
+ const middleware = createZodResponseMiddleware({
602
+ document,
603
+ // /users returns an array, so validating it as a single User must fail.
604
+ schemas: { 'get /users': 'User' },
605
+ });
606
+ const { api, queryClient } = setup({ middleware: [middleware] });
607
+
608
+ const error = await queryClient
609
+ .fetchQuery(api.queryOptions('get', '/users'))
610
+ .catch((thrown: unknown) => thrown);
611
+
612
+ expect((error as Error).name).toBe('ZodResponseValidationError');
613
+ expect((error as Error).message).toContain('User validation');
614
+ });
615
+
616
+ it('reports instead of throwing when onInvalid is supplied', async () => {
617
+ const onInvalid = vi.fn();
618
+ const middleware = createZodResponseMiddleware({
619
+ document,
620
+ schemas: { 'get /users': 'User' },
621
+ onInvalid,
622
+ });
623
+ const { api, queryClient } = setup({ middleware: [middleware] });
624
+
625
+ await expect(
626
+ queryClient.fetchQuery(api.queryOptions('get', '/users')),
627
+ ).resolves.toEqual([ada]);
628
+ expect(onInvalid).toHaveBeenCalledTimes(1);
629
+ expect(onInvalid.mock.calls[0]?.[0].schemaName).toBe('User');
630
+ expect(onInvalid.mock.calls[0]?.[0].issues.length).toBeGreaterThan(0);
631
+ });
632
+
633
+ it('skips operations with no mapped schema', async () => {
634
+ const middleware = createZodResponseMiddleware({
635
+ document,
636
+ schemas: (ctx) => (ctx.path === '/never' ? 'User' : undefined),
637
+ });
638
+ const { api, queryClient } = setup({ middleware: [middleware] });
639
+
640
+ await expect(
641
+ queryClient.fetchQuery(api.queryOptions('get', '/users')),
642
+ ).resolves.toEqual([ada]);
643
+ });
644
+ });