@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,225 @@
1
+ import { type ApiClient, createApiClient } from '@r0hitsharma/http-client-core';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import type { DataTag, QueryClient } from '@tanstack/react-query';
4
+ import { describe, expect, expectTypeOf, it } from 'vitest';
5
+
6
+ import { type HttpRequestError, isHttpRequestError } from './errors.js';
7
+ import { createQueryApi } from './query-api.js';
8
+ import type { QueryApi, QueryApiPaths } from './query-api.js';
9
+ import type { ApiFault, TestPaths, User } from './test-fixtures.js';
10
+
11
+ /**
12
+ * Type-level coverage of the inference the whole design rests on: methods,
13
+ * paths, params, request bodies, and response types all come off the generated
14
+ * `TPaths` type, and the single cast inside `createQueryApi` still lines up with
15
+ * the generic surface it claims.
16
+ *
17
+ * `expectTypeOf` and `@ts-expect-error` are checked by `npm run type:check`, so
18
+ * the assertions live in functions that are never called — several of them would
19
+ * throw or issue requests if they ran, and one calls `useQuery` outside a React
20
+ * render purely to borrow its overload resolution.
21
+ */
22
+
23
+ const client = createApiClient<TestPaths>('https://api.test');
24
+ const api = createQueryApi(client, { tags: ['users', 'user'] });
25
+
26
+ /**
27
+ * Stands in for `useQuery`, reading the data and error types back out of the
28
+ * branded query key so both can be asserted without rendering a hook.
29
+ */
30
+ declare function readTags<TData, TError>(options: {
31
+ queryKey: DataTag<readonly unknown[], TData, TError>;
32
+ }): { data: TData; error: TError };
33
+
34
+ type ResolvedQueryFn<
35
+ TOptions extends { queryFn: (...args: never[]) => unknown },
36
+ > = Awaited<ReturnType<TOptions['queryFn']>>;
37
+
38
+ describe('createQueryApi — inference', () => {
39
+ // A real assertion rather than a placeholder: the file's whole premise is
40
+ // that this api instance was constructed, and everything asserted below is
41
+ // about the options it builds.
42
+ it('builds options whose key is the operation', () => {
43
+ expect(api.queryOptions('get', '/users').queryKey).toEqual([
44
+ 'get',
45
+ '/users',
46
+ {},
47
+ ]);
48
+ });
49
+ });
50
+
51
+ export function typeAssertions(): void {
52
+ // The tag vocabulary narrows `TTag` — no explicit type argument needed.
53
+ expectTypeOf(api.tagFilter).parameter(0).toEqualTypeOf<'users' | 'user'>();
54
+ expectTypeOf(api.taggedEndpoints)
55
+ .parameter(0)
56
+ .toEqualTypeOf<'users' | 'user'>();
57
+
58
+ // Response data comes from the operation's 2xx response.
59
+ const listOptions = api.queryOptions('get', '/users', {
60
+ params: { query: { limit: 10 } },
61
+ });
62
+ expectTypeOf<ResolvedQueryFn<typeof listOptions>>().toEqualTypeOf<User[]>();
63
+
64
+ const listResult = readTags(listOptions);
65
+ expectTypeOf(listResult.data).toEqualTypeOf<User[]>();
66
+ expectTypeOf(listResult.error).toEqualTypeOf<
67
+ HttpRequestError<ApiFault> | Error
68
+ >();
69
+
70
+ // The derived key is `[method, path, sanitized(init)]`.
71
+ expectTypeOf(listOptions.queryKey).toExtend<
72
+ readonly ['get', '/users', { query?: Record<string, unknown> }]
73
+ >();
74
+
75
+ const detailOptions = api.queryOptions('get', '/users/{id}', {
76
+ params: { path: { id: 'u1' } },
77
+ });
78
+ expectTypeOf<ResolvedQueryFn<typeof detailOptions>>().toEqualTypeOf<User>();
79
+ expectTypeOf(readTags(detailOptions).error).toEqualTypeOf<
80
+ HttpRequestError<ApiFault> | Error
81
+ >();
82
+
83
+ // Narrowing needs no body type parameter: the guard filters the declared
84
+ // union, so the operation's own error body survives it — and stays optional,
85
+ // because a failure need not carry a body at all.
86
+ const failure = readTags(detailOptions).error;
87
+ if (isHttpRequestError(failure)) {
88
+ expectTypeOf(failure.body).toEqualTypeOf<ApiFault | undefined>();
89
+ }
90
+
91
+ // A `catch` binding declares nothing, so the body stays `unknown` — the guard
92
+ // has no type parameter with which to claim otherwise.
93
+ const caught = failure as unknown;
94
+ if (isHttpRequestError(caught)) {
95
+ expectTypeOf(caught.status).toEqualTypeOf<number>();
96
+ expectTypeOf(caught.body).toEqualTypeOf<unknown>();
97
+ }
98
+
99
+ // `select` retypes what a component reads without losing the fetched type.
100
+ const selectedOptions = api.queryOptions('get', '/users', undefined, {
101
+ select: (users) => users.length,
102
+ });
103
+ expectTypeOf(selectedOptions.select).parameter(0).toEqualTypeOf<User[]>();
104
+
105
+ // The assertion that matters: the options' `TData` is the *selected* type, so
106
+ // this is what a component reads. Asserted through the real `useQuery` rather
107
+ // than a stand-in, since matching its overload resolution is the point.
108
+ // This function never runs, so these two are type-level calls only — they
109
+ // borrow `useQuery`'s overload resolution rather than invoking a hook.
110
+ // eslint-disable-next-line react-hooks/rules-of-hooks
111
+ expectTypeOf(useQuery(selectedOptions).data).toEqualTypeOf<
112
+ number | undefined
113
+ >();
114
+ // eslint-disable-next-line react-hooks/rules-of-hooks
115
+ expectTypeOf(useQuery(listOptions).data).toEqualTypeOf<User[] | undefined>();
116
+
117
+ // And the key's brand still carries the *fetched* type, not the selected one:
118
+ // `getQueryData` on this key hands back what the endpoint returned.
119
+ expectTypeOf(readTags(selectedOptions).data).toEqualTypeOf<User[]>();
120
+
121
+ // HEAD is in the query method union, so a declared HEAD operation has to be
122
+ // reachable through it — and only on the path that declares one.
123
+ const headOptions = api.queryOptions('head', '/users', {
124
+ params: { query: { search: 'ada' } },
125
+ });
126
+ expectTypeOf(headOptions.queryKey).toExtend<
127
+ readonly ['head', '/users', { query?: Record<string, unknown> }]
128
+ >();
129
+
130
+ // @ts-expect-error — /users/{id} declares no HEAD operation
131
+ api.queryOptions('head', '/users/{id}', { params: { path: { id: 'u1' } } });
132
+
133
+ // @ts-expect-error — `params.path.id` is required by the operation
134
+ api.queryOptions('get', '/users/{id}');
135
+
136
+ // @ts-expect-error — `id` must be a string
137
+ api.queryOptions('get', '/users/{id}', { params: { path: { id: 7 } } });
138
+
139
+ // @ts-expect-error — the path is not in TestPaths
140
+ api.queryOptions('get', '/unknown');
141
+
142
+ // @ts-expect-error — /users has no DELETE operation
143
+ api.queryOptions('delete', '/users');
144
+
145
+ // @ts-expect-error — mutating methods do not belong on queryOptions
146
+ api.queryOptions('post', '/users');
147
+
148
+ // @ts-expect-error — 'orders' is not in the declared tag vocabulary
149
+ api.queryOptions('get', '/users', undefined, { tags: ['orders'] });
150
+
151
+ // Mutations: variables are the operation's init, data is the 2xx body.
152
+ const createOptions = api.mutationOptions('post', '/users', {
153
+ invalidates: ['users'],
154
+ });
155
+ expectTypeOf(createOptions.mutationFn)
156
+ .parameter(0)
157
+ .toExtend<{ body: { name: string } }>();
158
+ expectTypeOf<
159
+ Awaited<ReturnType<typeof createOptions.mutationFn>>
160
+ >().toEqualTypeOf<User>();
161
+ expectTypeOf(createOptions.mutationKey).toEqualTypeOf<
162
+ readonly ['post', '/users']
163
+ >();
164
+
165
+ // A tag-producing callback sees the mutation's own result.
166
+ api.mutationOptions('post', '/users', {
167
+ invalidates: [
168
+ (created) => {
169
+ expectTypeOf(created).toEqualTypeOf<User>();
170
+ return ['user', 'users'];
171
+ },
172
+ ],
173
+ });
174
+
175
+ // @ts-expect-error — 'orders' is not in the declared tag vocabulary
176
+ api.mutationOptions('post', '/users', { invalidates: ['orders'] });
177
+
178
+ // @ts-expect-error — read methods do not belong on mutationOptions
179
+ api.mutationOptions('get', '/users');
180
+
181
+ const deleteOptions = api.mutationOptions('delete', '/users/{id}');
182
+ expectTypeOf(deleteOptions.mutationFn)
183
+ .parameter(0)
184
+ .toExtend<{ params: { path: { id: string } } }>();
185
+
186
+ // The query key helper is branded with the same data and error types.
187
+ expectTypeOf(
188
+ readTags({ queryKey: api.queryKey('get', '/users') }).data,
189
+ ).toEqualTypeOf<User[]>();
190
+
191
+ // `QueryApiPaths` constrains `createQueryApi` itself, not just the option
192
+ // types it returns, so a paths type that is not a map of path → operations is
193
+ // rejected at the api's own signature.
194
+ const notPathsClient = {} as ApiClient<{ '/users': string }>;
195
+ // @ts-expect-error — `string` is not a map of HTTP method to operation
196
+ createQueryApi<{ '/users': string }>(notPathsClient);
197
+
198
+ // Inferring the same bad paths type off the client is the case the constraint
199
+ // cannot reject: inference finds no candidate and falls back to the
200
+ // constraint, so the api is typed `QueryApi<QueryApiPaths>` and it is the
201
+ // calls on it that fail, not the construction.
202
+ expectTypeOf(createQueryApi(notPathsClient)).toEqualTypeOf<
203
+ QueryApi<QueryApiPaths>
204
+ >();
205
+ }
206
+
207
+ /**
208
+ * An operation whose init is entirely optional must be callable with two
209
+ * arguments — including in a contextually typed position, which is where the
210
+ * `NoInfer` wrappers on the return types earn their keep.
211
+ */
212
+ export function optionalInitArity(queryClient: QueryClient): void {
213
+ api.queryKey('get', '/users');
214
+ api.queryOptions('get', '/users');
215
+ api.mutationOptions('post', '/users');
216
+
217
+ const key: readonly unknown[] = api.queryKey('get', '/users');
218
+ void key;
219
+
220
+ void queryClient.invalidateQueries({
221
+ queryKey: api.queryKey('get', '/users'),
222
+ });
223
+ void queryClient.getQueryState(api.queryKey('get', '/users'));
224
+ void queryClient.fetchQuery(api.queryOptions('get', '/users'));
225
+ }
@@ -0,0 +1,315 @@
1
+ import {
2
+ environmentManager,
3
+ QueryCache,
4
+ QueryClient,
5
+ type QueryClientConfig,
6
+ QueryObserver,
7
+ } from '@tanstack/react-query';
8
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
9
+
10
+ import { HttpRequestError } from './errors.js';
11
+ import {
12
+ createQueryClient,
13
+ isRetryableError,
14
+ isRetryableHttpStatus,
15
+ shouldRetryRequest,
16
+ } from './query-client.js';
17
+ import { ZodResponseValidationError } from './zod-response.js';
18
+
19
+ /** An `HttpRequestError` as `createQueryApi` would build it for `status`. */
20
+ function httpError(status: number): HttpRequestError {
21
+ return new HttpRequestError({
22
+ method: 'get',
23
+ path: '/users',
24
+ body: undefined,
25
+ response: new Response(null, { status }),
26
+ });
27
+ }
28
+
29
+ /** A `ZodResponseValidationError` as the zod middleware would build it. */
30
+ function validationError(): ZodResponseValidationError {
31
+ return new ZodResponseValidationError({
32
+ method: 'get',
33
+ path: '/users/{id}',
34
+ schemaName: 'User',
35
+ issues: [{ path: 'name', message: 'expected string, received undefined' }],
36
+ });
37
+ }
38
+
39
+ describe('isRetryableHttpStatus', () => {
40
+ // The four names below are the whole policy; the table specs under
41
+ // `shouldRetryRequest` exercise it through the exported predicate.
42
+ it.for([408, 425, 429])('retries the transient 4xx %i', (status) => {
43
+ expect(isRetryableHttpStatus(status)).toBe(true);
44
+ });
45
+
46
+ it.for([400, 401, 403, 404, 409, 410, 418, 422, 451, 499])(
47
+ 'does not retry the client fault %i',
48
+ (status) => {
49
+ expect(isRetryableHttpStatus(status)).toBe(false);
50
+ },
51
+ );
52
+
53
+ it.for([500, 502, 503, 504, 599])('retries the server fault %i', (status) => {
54
+ expect(isRetryableHttpStatus(status)).toBe(true);
55
+ });
56
+
57
+ it.for([200, 204, 301, 304])(
58
+ 'does not retry the sub-400 status %i',
59
+ (status) => {
60
+ expect(isRetryableHttpStatus(status)).toBe(false);
61
+ },
62
+ );
63
+ });
64
+
65
+ describe('isRetryableError', () => {
66
+ it('reads the status off an HttpRequestError', () => {
67
+ expect(isRetryableError(httpError(429))).toBe(true);
68
+ expect(isRetryableError(httpError(422))).toBe(false);
69
+ });
70
+
71
+ it('retries a rejection that never reached a status', () => {
72
+ // A dropped connection, a CORS refusal, or a middleware that threw: the
73
+ // only evidence is that the request did not complete.
74
+ expect(isRetryableError(new TypeError('Failed to fetch'))).toBe(true);
75
+ expect(isRetryableError(new Error('response validation failed'))).toBe(
76
+ true,
77
+ );
78
+ expect(isRetryableError('not an error at all')).toBe(true);
79
+ });
80
+
81
+ it('narrows on `name`, not `instanceof`', () => {
82
+ // Two copies of this package in one module graph produce an error that is
83
+ // not `instanceof` the class this predicate closed over.
84
+ const foreign = Object.assign(new Error('HTTP 404'), {
85
+ name: 'HttpRequestError',
86
+ status: 404,
87
+ });
88
+
89
+ expect(isRetryableError(foreign)).toBe(false);
90
+ });
91
+
92
+ it('does not retry a body that failed response validation', () => {
93
+ // The one rejection without a status that still means the request
94
+ // completed: the middleware rejects after a 2xx arrived and parsed.
95
+ expect(isRetryableError(validationError())).toBe(false);
96
+ });
97
+
98
+ it('narrows the validation error on `name` too', () => {
99
+ const foreign = Object.assign(new Error('User validation failed'), {
100
+ name: 'ZodResponseValidationError',
101
+ });
102
+
103
+ expect(isRetryableError(foreign)).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('shouldRetryRequest', () => {
108
+ it('allows two retries, then stops', () => {
109
+ // `failureCount` is the number of failures *before* this attempt, so the
110
+ // first rejection arrives as 0. Two `true`s means three attempts total.
111
+ expect(shouldRetryRequest(0, httpError(503))).toBe(true);
112
+ expect(shouldRetryRequest(1, httpError(503))).toBe(true);
113
+ expect(shouldRetryRequest(2, httpError(503))).toBe(false);
114
+ });
115
+
116
+ it('stops on a client fault at the first failure', () => {
117
+ expect(shouldRetryRequest(0, httpError(404))).toBe(false);
118
+ });
119
+ });
120
+
121
+ describe('createQueryClient', () => {
122
+ it('installs the dashboard defaults', () => {
123
+ const queries = createQueryClient().getDefaultOptions().queries;
124
+
125
+ expect(queries?.refetchOnWindowFocus).toBe(false);
126
+ expect(queries?.retry).toBe(shouldRetryRequest);
127
+ });
128
+
129
+ it('leaves mutations un-retried', () => {
130
+ expect(
131
+ createQueryClient().getDefaultOptions().mutations?.retry,
132
+ ).toBeUndefined();
133
+ });
134
+
135
+ it('replaces one default without dropping the others', () => {
136
+ const queries = createQueryClient({
137
+ defaultOptions: { queries: { retry: 5 } },
138
+ }).getDefaultOptions().queries;
139
+
140
+ expect(queries?.retry).toBe(5);
141
+ expect(queries?.refetchOnWindowFocus).toBe(false);
142
+ });
143
+
144
+ it('does not read a present-but-undefined key as an override', () => {
145
+ // How conditional config is ordinarily written. A raw spread would copy
146
+ // `retry: undefined` over the predicate, and react-query resolves that as
147
+ // `retry ?? 3` — the retry-everything default this module exists to
148
+ // replace, reinstated by a branch that meant to change nothing.
149
+ const alwaysFail = false as boolean;
150
+
151
+ const queries = createQueryClient({
152
+ defaultOptions: {
153
+ queries: {
154
+ retry: alwaysFail ? false : undefined,
155
+ refetchOnWindowFocus: undefined,
156
+ },
157
+ },
158
+ }).getDefaultOptions().queries;
159
+
160
+ expect(queries?.retry).toBe(shouldRetryRequest);
161
+ expect(queries?.refetchOnWindowFocus).toBe(false);
162
+ });
163
+
164
+ it('keeps the defined keys of a partly-undefined override', () => {
165
+ const queries = createQueryClient({
166
+ defaultOptions: { queries: { retry: undefined, staleTime: 30_000 } },
167
+ }).getDefaultOptions().queries;
168
+
169
+ expect(queries?.retry).toBe(shouldRetryRequest);
170
+ expect(queries?.staleTime).toBe(30_000);
171
+ });
172
+
173
+ it('passes the rest of QueryClientConfig through untouched', () => {
174
+ const queryCache = new QueryCache();
175
+
176
+ const client = createQueryClient({
177
+ queryCache,
178
+ defaultOptions: { mutations: { retry: 1 }, dehydrate: {} },
179
+ });
180
+
181
+ expect(client.getQueryCache()).toBe(queryCache);
182
+ expect(client.getDefaultOptions().mutations?.retry).toBe(1);
183
+ expect(client.getDefaultOptions().dehydrate).toEqual({});
184
+ expect(client.getDefaultOptions().queries?.refetchOnWindowFocus).toBe(
185
+ false,
186
+ );
187
+ });
188
+
189
+ it('returns a distinct client per call', () => {
190
+ expect(createQueryClient()).not.toBe(createQueryClient());
191
+ });
192
+
193
+ it('is a real QueryClient', () => {
194
+ expect(createQueryClient()).toBeInstanceOf(QueryClient);
195
+ });
196
+ });
197
+
198
+ describe('the retry policy a real client applies', () => {
199
+ /**
200
+ * How many times a query rejecting with `error` is attempted under the
201
+ * package defaults. Only `retryDelay` is overridden — `retry` is the default
202
+ * under test — so the exponential backoff does not make the spec wait out
203
+ * the three seconds a real screen would.
204
+ */
205
+ async function countAttempts(error: unknown): Promise<number> {
206
+ let attempts = 0;
207
+
208
+ await createQueryClient({ defaultOptions: { queries: { retryDelay: 0 } } })
209
+ .fetchQuery({
210
+ queryKey: ['attempts'],
211
+ queryFn: () => {
212
+ attempts += 1;
213
+ return Promise.reject(error);
214
+ },
215
+ })
216
+ .catch(() => undefined);
217
+
218
+ return attempts;
219
+ }
220
+
221
+ it('asks once when the response body drifted', async () => {
222
+ // Before the validation carve-out this was 3, and with the default
223
+ // backoff the error reached the screen three seconds late.
224
+ await expect(countAttempts(validationError())).resolves.toBe(1);
225
+ });
226
+
227
+ it('asks once on a client fault', async () => {
228
+ await expect(countAttempts(httpError(422))).resolves.toBe(1);
229
+ });
230
+
231
+ // The rest of the statusless branch is unchanged: no status and no verdict
232
+ // from the server means the only reading available is that the request did
233
+ // not complete.
234
+ it.for([
235
+ ['a dropped connection', new TypeError('Failed to fetch')],
236
+ ['an abort', new DOMException('The operation was aborted.', 'AbortError')],
237
+ [
238
+ 'a middleware that threw',
239
+ new Error('http-client-react: middleware called next() more than once'),
240
+ ],
241
+ ] as const)('still retries %s twice', async ([, error]) => {
242
+ await expect(countAttempts(error)).resolves.toBe(3);
243
+ });
244
+
245
+ it('retries a server fault twice', async () => {
246
+ await expect(countAttempts(httpError(503))).resolves.toBe(3);
247
+ });
248
+ });
249
+
250
+ describe('the retry policy a mounted query applies', () => {
251
+ // `fetchQuery` rewrites an `undefined` retry to `false`, so `countAttempts`
252
+ // above reads 1 whether the predicate is installed or missing — it cannot
253
+ // see a retry default that went away. A subscribed observer is the
254
+ // `useQuery` path, where react-query falls back to `retry ?? 3` instead, and
255
+ // the only place a lost predicate costs visible round trips.
256
+ const serverByDefault = environmentManager.isServer();
257
+
258
+ // That fallback is `?? 0` on the server, and these specs run under `node`.
259
+ beforeAll(() => environmentManager.setIsServer(() => false));
260
+ afterAll(() => environmentManager.setIsServer(() => serverByDefault));
261
+
262
+ /** How many times a *mounted* query rejecting with `error` is attempted. */
263
+ async function countMountedAttempts(
264
+ config: QueryClientConfig,
265
+ error: unknown,
266
+ ): Promise<number> {
267
+ let attempts = 0;
268
+
269
+ const observer = new QueryObserver(createQueryClient(config), {
270
+ queryKey: ['mounted-attempts'],
271
+ queryFn: () => {
272
+ attempts += 1;
273
+ return Promise.reject(error);
274
+ },
275
+ retryDelay: 0,
276
+ });
277
+
278
+ await new Promise<void>((resolve) => {
279
+ const unsubscribe = observer.subscribe((result) => {
280
+ if (result.status === 'error') {
281
+ unsubscribe();
282
+ resolve();
283
+ }
284
+ });
285
+ });
286
+
287
+ return attempts;
288
+ }
289
+
290
+ it('asks once on a client fault', async () => {
291
+ await expect(countMountedAttempts({}, httpError(422))).resolves.toBe(1);
292
+ });
293
+
294
+ it('asks once when an override names `retry` as undefined', async () => {
295
+ // The shape a conditional override is ordinarily written in. Before the
296
+ // merge dropped undefined-valued keys this was 4 — react-query's own
297
+ // `retry ?? 3`, reinstated by a branch that meant to change nothing.
298
+ const alwaysFail = false as boolean;
299
+
300
+ await expect(
301
+ countMountedAttempts(
302
+ {
303
+ defaultOptions: {
304
+ queries: { retry: alwaysFail ? false : undefined },
305
+ },
306
+ },
307
+ httpError(422),
308
+ ),
309
+ ).resolves.toBe(1);
310
+ });
311
+
312
+ it('retries a server fault twice', async () => {
313
+ await expect(countMountedAttempts({}, httpError(503))).resolves.toBe(3);
314
+ });
315
+ });