@hypequery/react 0.0.0-canary-20260611154523
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.
- package/LICENSE +201 -0
- package/README.md +259 -0
- package/dist/analyticsHooks.d.ts +31 -0
- package/dist/analyticsHooks.d.ts.map +1 -0
- package/dist/analyticsHooks.js +18 -0
- package/dist/analyticsHooks.test.d.ts +2 -0
- package/dist/analyticsHooks.test.d.ts.map +1 -0
- package/dist/analyticsHooks.test.js +98 -0
- package/dist/createHooks.d.ts +49 -0
- package/dist/createHooks.d.ts.map +1 -0
- package/dist/createHooks.js +177 -0
- package/dist/createHooks.test.d.ts +2 -0
- package/dist/createHooks.test.d.ts.map +1 -0
- package/dist/createHooks.test.js +686 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
import { createHooks, queryOptions } from './createHooks.js';
|
|
6
|
+
import { HttpError } from './errors.js';
|
|
7
|
+
function createWrapper() {
|
|
8
|
+
const queryClient = new QueryClient({
|
|
9
|
+
defaultOptions: {
|
|
10
|
+
queries: { retry: false },
|
|
11
|
+
mutations: { retry: false },
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
return ({ children }) => (_jsx(QueryClientProvider, { client: queryClient, children: children }));
|
|
15
|
+
}
|
|
16
|
+
function mockSuccessResponse(data) {
|
|
17
|
+
return {
|
|
18
|
+
ok: true,
|
|
19
|
+
status: 200,
|
|
20
|
+
json: () => Promise.resolve(data),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function mockErrorResponse(status, body) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
status,
|
|
27
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
describe('createHooks', () => {
|
|
31
|
+
describe('Basic functionality', () => {
|
|
32
|
+
const fetchMock = vi.fn();
|
|
33
|
+
const { useQuery, useMutation } = createHooks({
|
|
34
|
+
baseUrl: 'https://example.com/api',
|
|
35
|
+
fetchFn: fetchMock,
|
|
36
|
+
});
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
fetchMock.mockReset();
|
|
39
|
+
});
|
|
40
|
+
it('executes queries via fetch', async () => {
|
|
41
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ total: 42 }));
|
|
42
|
+
const { result } = renderHook(() => useQuery('weeklyRevenue', { startDate: '2025-01-01' }), {
|
|
43
|
+
wrapper: createWrapper(),
|
|
44
|
+
});
|
|
45
|
+
await waitFor(() => {
|
|
46
|
+
expect(result.current.data).toEqual({ total: 42 });
|
|
47
|
+
});
|
|
48
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/weeklyRevenue?startDate=2025-01-01', expect.objectContaining({ method: 'GET' }));
|
|
49
|
+
});
|
|
50
|
+
it('executes mutations via fetch', async () => {
|
|
51
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
52
|
+
const { result } = renderHook(() => useMutation('rebuildMetrics'), {
|
|
53
|
+
wrapper: createWrapper()
|
|
54
|
+
});
|
|
55
|
+
await result.current.mutateAsync({ force: true });
|
|
56
|
+
// Mutations default to POST
|
|
57
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/rebuildMetrics', expect.objectContaining({
|
|
58
|
+
method: 'POST',
|
|
59
|
+
body: JSON.stringify({ force: true }),
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
it('allows queries with no input parameters', async () => {
|
|
63
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
|
|
64
|
+
const { result } = renderHook(() => useQuery('noInput'), {
|
|
65
|
+
wrapper: createWrapper(),
|
|
66
|
+
});
|
|
67
|
+
await waitFor(() => expect(result.current.data).toEqual({ status: 'ok' }));
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/noInput', expect.objectContaining({ method: 'GET' }));
|
|
69
|
+
});
|
|
70
|
+
it('invokes header factory per request', async () => {
|
|
71
|
+
const headerFactory = vi.fn()
|
|
72
|
+
.mockReturnValueOnce({ 'x-token': 'first' })
|
|
73
|
+
.mockReturnValueOnce({ 'x-token': 'second' });
|
|
74
|
+
const { useQuery } = createHooks({
|
|
75
|
+
baseUrl: 'https://example.com/api',
|
|
76
|
+
fetchFn: fetchMock,
|
|
77
|
+
headers: headerFactory,
|
|
78
|
+
});
|
|
79
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ name: 'User' }));
|
|
80
|
+
const first = renderHook(() => useQuery('getUser', { id: '1' }), {
|
|
81
|
+
wrapper: createWrapper(),
|
|
82
|
+
});
|
|
83
|
+
await waitFor(() => {
|
|
84
|
+
expect(first.result.current.data).toEqual({ name: 'User' });
|
|
85
|
+
});
|
|
86
|
+
const second = renderHook(() => useQuery('getUser', { id: '2' }), {
|
|
87
|
+
wrapper: createWrapper(),
|
|
88
|
+
});
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(second.result.current.data).toEqual({ name: 'User' });
|
|
91
|
+
});
|
|
92
|
+
expect(headerFactory).toHaveBeenCalledTimes(2);
|
|
93
|
+
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ 'x-token': 'first' });
|
|
94
|
+
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ 'x-token': 'second' });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('HTTP Method Handling', () => {
|
|
98
|
+
const fetchMock = vi.fn();
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
fetchMock.mockReset();
|
|
101
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
102
|
+
});
|
|
103
|
+
it('defaults to GET when no config provided', async () => {
|
|
104
|
+
const { useQuery } = createHooks({
|
|
105
|
+
baseUrl: 'https://example.com/api',
|
|
106
|
+
fetchFn: fetchMock,
|
|
107
|
+
});
|
|
108
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
109
|
+
wrapper: createWrapper(),
|
|
110
|
+
});
|
|
111
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('?'), expect.objectContaining({ method: 'GET' }));
|
|
113
|
+
});
|
|
114
|
+
it('uses POST when explicitly configured', async () => {
|
|
115
|
+
const { useQuery } = createHooks({
|
|
116
|
+
baseUrl: 'https://example.com/api',
|
|
117
|
+
fetchFn: fetchMock,
|
|
118
|
+
config: {
|
|
119
|
+
getUser: { method: 'POST' },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
123
|
+
wrapper: createWrapper(),
|
|
124
|
+
});
|
|
125
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
126
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({
|
|
127
|
+
method: 'POST',
|
|
128
|
+
body: JSON.stringify({ id: '123' }),
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
it('extracts method config from API object', async () => {
|
|
132
|
+
const mockApi = {
|
|
133
|
+
queries: {
|
|
134
|
+
getUser: { method: 'GET' },
|
|
135
|
+
updateUser: { method: 'PATCH' },
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const { useQuery } = createHooks({
|
|
139
|
+
baseUrl: 'https://example.com/api',
|
|
140
|
+
fetchFn: fetchMock,
|
|
141
|
+
api: mockApi,
|
|
142
|
+
});
|
|
143
|
+
const { result } = renderHook(() => useQuery('updateUser', { id: '123', name: 'John' }), {
|
|
144
|
+
wrapper: createWrapper(),
|
|
145
|
+
});
|
|
146
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
147
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'PATCH' }));
|
|
148
|
+
});
|
|
149
|
+
it('prefers route-level config over endpoint config', async () => {
|
|
150
|
+
const mockApi = {
|
|
151
|
+
queries: {
|
|
152
|
+
getUser: { method: 'POST' },
|
|
153
|
+
},
|
|
154
|
+
_routeConfig: {
|
|
155
|
+
getUser: { method: 'GET' },
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const { useQuery } = createHooks({
|
|
159
|
+
baseUrl: 'https://example.com/api',
|
|
160
|
+
fetchFn: fetchMock,
|
|
161
|
+
api: mockApi,
|
|
162
|
+
});
|
|
163
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
164
|
+
wrapper: createWrapper(),
|
|
165
|
+
});
|
|
166
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
167
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'GET' }));
|
|
168
|
+
});
|
|
169
|
+
it('allows explicit config to override API config', async () => {
|
|
170
|
+
const mockApi = {
|
|
171
|
+
queries: {
|
|
172
|
+
getUser: { method: 'GET' },
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const { useQuery } = createHooks({
|
|
176
|
+
baseUrl: 'https://example.com/api',
|
|
177
|
+
fetchFn: fetchMock,
|
|
178
|
+
api: mockApi,
|
|
179
|
+
config: {
|
|
180
|
+
getUser: { method: 'POST' },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
184
|
+
wrapper: createWrapper(),
|
|
185
|
+
});
|
|
186
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
187
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({ method: 'POST' }));
|
|
188
|
+
});
|
|
189
|
+
it('resolves root-relative config paths against an absolute baseUrl host', async () => {
|
|
190
|
+
const { useQuery } = createHooks({
|
|
191
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
192
|
+
fetchFn: fetchMock,
|
|
193
|
+
config: {
|
|
194
|
+
getUser: { method: 'POST', path: '/api/analytics/queries/getUser' },
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
198
|
+
wrapper: createWrapper(),
|
|
199
|
+
});
|
|
200
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
201
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/queries/getUser', expect.objectContaining({
|
|
202
|
+
method: 'POST',
|
|
203
|
+
body: JSON.stringify({ id: '123' }),
|
|
204
|
+
}));
|
|
205
|
+
});
|
|
206
|
+
it('resolves relative config paths against baseUrl', async () => {
|
|
207
|
+
const { useQuery } = createHooks({
|
|
208
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
209
|
+
fetchFn: fetchMock,
|
|
210
|
+
config: {
|
|
211
|
+
getUser: { method: 'POST', path: 'queries/getUser' },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
215
|
+
wrapper: createWrapper(),
|
|
216
|
+
});
|
|
217
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
218
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/hypequery/queries/getUser', expect.objectContaining({
|
|
219
|
+
method: 'POST',
|
|
220
|
+
body: JSON.stringify({ id: '123' }),
|
|
221
|
+
}));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
describe('Route manifest', () => {
|
|
225
|
+
const fetchMock = vi.fn();
|
|
226
|
+
beforeEach(() => {
|
|
227
|
+
fetchMock.mockReset();
|
|
228
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
229
|
+
});
|
|
230
|
+
it('resolves method and path from a manifest', async () => {
|
|
231
|
+
const { useQuery } = createHooks({
|
|
232
|
+
baseUrl: 'https://api.example.com',
|
|
233
|
+
fetchFn: fetchMock,
|
|
234
|
+
manifest: {
|
|
235
|
+
getUser: { method: 'POST', path: '/api/analytics/metrics/getUser' },
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
239
|
+
wrapper: createWrapper(),
|
|
240
|
+
});
|
|
241
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
242
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/metrics/getUser', expect.objectContaining({
|
|
243
|
+
method: 'POST',
|
|
244
|
+
body: JSON.stringify({ id: '123' }),
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
247
|
+
it('lets explicit config override the manifest', async () => {
|
|
248
|
+
const { useQuery } = createHooks({
|
|
249
|
+
baseUrl: 'https://api.example.com',
|
|
250
|
+
fetchFn: fetchMock,
|
|
251
|
+
manifest: {
|
|
252
|
+
getUser: { method: 'POST', path: '/manifest/getUser' },
|
|
253
|
+
},
|
|
254
|
+
config: {
|
|
255
|
+
getUser: { method: 'POST', path: '/override/getUser' },
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
259
|
+
wrapper: createWrapper(),
|
|
260
|
+
});
|
|
261
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
262
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/override/getUser', expect.objectContaining({ method: 'POST' }));
|
|
263
|
+
});
|
|
264
|
+
it('derives method config from an api object exposing manifest()', async () => {
|
|
265
|
+
const mockApi = {
|
|
266
|
+
manifest: () => ({
|
|
267
|
+
getUser: { method: 'PATCH', path: '/api/analytics/queries/getUser' },
|
|
268
|
+
}),
|
|
269
|
+
};
|
|
270
|
+
const { useQuery } = createHooks({
|
|
271
|
+
baseUrl: 'https://api.example.com',
|
|
272
|
+
fetchFn: fetchMock,
|
|
273
|
+
api: mockApi,
|
|
274
|
+
});
|
|
275
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
276
|
+
wrapper: createWrapper(),
|
|
277
|
+
});
|
|
278
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
279
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/queries/getUser', expect.objectContaining({ method: 'PATCH' }));
|
|
280
|
+
});
|
|
281
|
+
it('throws for an unresolved semantic (dataset:) key', async () => {
|
|
282
|
+
const { useQuery } = createHooks({
|
|
283
|
+
baseUrl: 'https://api.example.com',
|
|
284
|
+
fetchFn: fetchMock,
|
|
285
|
+
});
|
|
286
|
+
const { result } = renderHook(() => useQuery('dataset:orders', { dimensions: ['country'] }), { wrapper: createWrapper() });
|
|
287
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
288
|
+
expect(result.current.error?.message).toContain('No route configured for "dataset:orders"');
|
|
289
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
describe('Query Parameter Serialization', () => {
|
|
293
|
+
const fetchMock = vi.fn();
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
fetchMock.mockReset();
|
|
296
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
297
|
+
});
|
|
298
|
+
it('serializes simple objects as query params for GET requests', async () => {
|
|
299
|
+
const { useQuery } = createHooks({
|
|
300
|
+
baseUrl: 'https://example.com/api',
|
|
301
|
+
fetchFn: fetchMock,
|
|
302
|
+
});
|
|
303
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
304
|
+
wrapper: createWrapper(),
|
|
305
|
+
});
|
|
306
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
307
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser?id=123', expect.objectContaining({ method: 'GET' }));
|
|
308
|
+
});
|
|
309
|
+
it('serializes arrays as multiple query params', async () => {
|
|
310
|
+
const { useQuery } = createHooks({
|
|
311
|
+
baseUrl: 'https://example.com/api',
|
|
312
|
+
fetchFn: fetchMock,
|
|
313
|
+
});
|
|
314
|
+
const { result } = renderHook(() => useQuery('listItems', { tags: ['react', 'typescript'], limit: 10 }), { wrapper: createWrapper() });
|
|
315
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
316
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
317
|
+
expect(callUrl).toContain('tags=react');
|
|
318
|
+
expect(callUrl).toContain('tags=typescript');
|
|
319
|
+
expect(callUrl).toContain('limit=10');
|
|
320
|
+
});
|
|
321
|
+
it('skips undefined and null values in query params', async () => {
|
|
322
|
+
const { useQuery } = createHooks({
|
|
323
|
+
baseUrl: 'https://example.com/api',
|
|
324
|
+
fetchFn: fetchMock,
|
|
325
|
+
});
|
|
326
|
+
const { result } = renderHook(() => useQuery('rebuildMetrics', { force: undefined }), { wrapper: createWrapper() });
|
|
327
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
328
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
329
|
+
expect(callUrl).not.toContain('force');
|
|
330
|
+
});
|
|
331
|
+
it('handles empty objects in GET requests', async () => {
|
|
332
|
+
const { useQuery } = createHooks({
|
|
333
|
+
baseUrl: 'https://example.com/api',
|
|
334
|
+
fetchFn: fetchMock,
|
|
335
|
+
});
|
|
336
|
+
const { result } = renderHook(() => useQuery('rebuildMetrics', {}), { wrapper: createWrapper() });
|
|
337
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
338
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/rebuildMetrics', expect.objectContaining({ method: 'GET' }));
|
|
339
|
+
});
|
|
340
|
+
it('uses JSON body for POST requests', async () => {
|
|
341
|
+
const { useQuery } = createHooks({
|
|
342
|
+
baseUrl: 'https://example.com/api',
|
|
343
|
+
fetchFn: fetchMock,
|
|
344
|
+
config: {
|
|
345
|
+
getUser: { method: 'POST' },
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
349
|
+
wrapper: createWrapper(),
|
|
350
|
+
});
|
|
351
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
352
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({
|
|
353
|
+
method: 'POST',
|
|
354
|
+
body: JSON.stringify({ id: '123' }),
|
|
355
|
+
headers: expect.objectContaining({
|
|
356
|
+
'content-type': 'application/json',
|
|
357
|
+
}),
|
|
358
|
+
}));
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
describe('Error Handling', () => {
|
|
362
|
+
const fetchMock = vi.fn();
|
|
363
|
+
beforeEach(() => {
|
|
364
|
+
fetchMock.mockReset();
|
|
365
|
+
});
|
|
366
|
+
it('throws error with detailed message on failed request', async () => {
|
|
367
|
+
fetchMock.mockResolvedValue(mockErrorResponse(404, { message: 'Not found' }));
|
|
368
|
+
const { useQuery } = createHooks({
|
|
369
|
+
baseUrl: 'https://example.com/api',
|
|
370
|
+
fetchFn: fetchMock,
|
|
371
|
+
});
|
|
372
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
373
|
+
wrapper: createWrapper(),
|
|
374
|
+
});
|
|
375
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
376
|
+
expect(result.current.error).toBeInstanceOf(HttpError);
|
|
377
|
+
expect(result.current.error?.message).toContain('GET request to');
|
|
378
|
+
expect(result.current.error?.message).toContain('failed with status 404');
|
|
379
|
+
expect(result.current.error?.status).toBe(404);
|
|
380
|
+
expect(result.current.error?.body).toEqual({ message: 'Not found' });
|
|
381
|
+
});
|
|
382
|
+
it('parses JSON error responses', async () => {
|
|
383
|
+
fetchMock.mockResolvedValue(mockErrorResponse(400, { error: 'Bad request' }));
|
|
384
|
+
const { useQuery } = createHooks({
|
|
385
|
+
baseUrl: 'https://example.com/api',
|
|
386
|
+
fetchFn: fetchMock,
|
|
387
|
+
});
|
|
388
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
389
|
+
wrapper: createWrapper(),
|
|
390
|
+
});
|
|
391
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
392
|
+
expect(result.current.error).toBeInstanceOf(HttpError);
|
|
393
|
+
expect(result.current.error?.body).toEqual({ error: 'Bad request' });
|
|
394
|
+
});
|
|
395
|
+
it('parses text error responses', async () => {
|
|
396
|
+
fetchMock.mockResolvedValue({
|
|
397
|
+
ok: false,
|
|
398
|
+
status: 500,
|
|
399
|
+
text: () => Promise.resolve('Internal server error'),
|
|
400
|
+
});
|
|
401
|
+
const { useQuery } = createHooks({
|
|
402
|
+
baseUrl: 'https://example.com/api',
|
|
403
|
+
fetchFn: fetchMock,
|
|
404
|
+
});
|
|
405
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
406
|
+
wrapper: createWrapper(),
|
|
407
|
+
});
|
|
408
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
409
|
+
expect(result.current.error).toBeInstanceOf(HttpError);
|
|
410
|
+
expect(result.current.error?.body).toBe('Internal server error');
|
|
411
|
+
});
|
|
412
|
+
it('handles network errors', async () => {
|
|
413
|
+
fetchMock.mockRejectedValue(new Error('Network error'));
|
|
414
|
+
const { useQuery } = createHooks({
|
|
415
|
+
baseUrl: 'https://example.com/api',
|
|
416
|
+
fetchFn: fetchMock,
|
|
417
|
+
});
|
|
418
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
419
|
+
wrapper: createWrapper(),
|
|
420
|
+
});
|
|
421
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
422
|
+
expect(result.current.error?.message).toBe('Network error');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
describe('useQuery Argument Overloads', () => {
|
|
426
|
+
const fetchMock = vi.fn();
|
|
427
|
+
beforeEach(() => {
|
|
428
|
+
fetchMock.mockReset();
|
|
429
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
|
|
430
|
+
});
|
|
431
|
+
it('handles useQuery(name) with no input', async () => {
|
|
432
|
+
const { useQuery } = createHooks({
|
|
433
|
+
baseUrl: 'https://example.com/api',
|
|
434
|
+
fetchFn: fetchMock,
|
|
435
|
+
});
|
|
436
|
+
const { result } = renderHook(() => useQuery('noInput'), {
|
|
437
|
+
wrapper: createWrapper(),
|
|
438
|
+
});
|
|
439
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
440
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/noInput', expect.any(Object));
|
|
441
|
+
});
|
|
442
|
+
it('handles useQuery(name, input)', async () => {
|
|
443
|
+
const { useQuery } = createHooks({
|
|
444
|
+
baseUrl: 'https://example.com/api',
|
|
445
|
+
fetchFn: fetchMock,
|
|
446
|
+
});
|
|
447
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
448
|
+
wrapper: createWrapper(),
|
|
449
|
+
});
|
|
450
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
451
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
452
|
+
});
|
|
453
|
+
it('handles useQuery(name, options) - options only', async () => {
|
|
454
|
+
const { useQuery } = createHooks({
|
|
455
|
+
baseUrl: 'https://example.com/api',
|
|
456
|
+
fetchFn: fetchMock,
|
|
457
|
+
});
|
|
458
|
+
const { result } = renderHook(() => useQuery('noInput', { enabled: false, staleTime: 5000 }), { wrapper: createWrapper() });
|
|
459
|
+
// Should not fetch because enabled: false
|
|
460
|
+
expect(result.current.isSuccess).toBe(false);
|
|
461
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
it('handles useQuery(name, input, options)', async () => {
|
|
464
|
+
const { useQuery } = createHooks({
|
|
465
|
+
baseUrl: 'https://example.com/api',
|
|
466
|
+
fetchFn: fetchMock,
|
|
467
|
+
});
|
|
468
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }, { staleTime: 5000 }), { wrapper: createWrapper() });
|
|
469
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
470
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
it('correctly distinguishes input from options using isQueryOptions', async () => {
|
|
473
|
+
const { useQuery } = createHooks({
|
|
474
|
+
baseUrl: 'https://example.com/api',
|
|
475
|
+
fetchFn: fetchMock,
|
|
476
|
+
});
|
|
477
|
+
// Input with 'enabled' field should be treated as input, not options
|
|
478
|
+
const { result: result1 } = renderHook(() => useQuery('rebuildMetrics', { force: true }), { wrapper: createWrapper() });
|
|
479
|
+
await waitFor(() => expect(result1.current.isSuccess).toBe(true));
|
|
480
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
481
|
+
fetchMock.mockClear();
|
|
482
|
+
// Options object should disable the query
|
|
483
|
+
const { result: result2 } = renderHook(() => useQuery('noInput', { enabled: false, staleTime: 1000 }), { wrapper: createWrapper() });
|
|
484
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
485
|
+
});
|
|
486
|
+
it('uses queryOptions() helper for explicit option marking', async () => {
|
|
487
|
+
const { useQuery } = createHooks({
|
|
488
|
+
baseUrl: 'https://example.com/api',
|
|
489
|
+
fetchFn: fetchMock,
|
|
490
|
+
});
|
|
491
|
+
// Using queryOptions() helper ensures options are recognized
|
|
492
|
+
const { result } = renderHook(() => useQuery('noInput', queryOptions({ enabled: false })), { wrapper: createWrapper() });
|
|
493
|
+
// Query should be disabled
|
|
494
|
+
expect(result.current.isLoading).toBe(false);
|
|
495
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
describe('Configuration', () => {
|
|
499
|
+
const fetchMock = vi.fn();
|
|
500
|
+
beforeEach(() => {
|
|
501
|
+
fetchMock.mockReset();
|
|
502
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
503
|
+
});
|
|
504
|
+
it('includes custom headers in requests', async () => {
|
|
505
|
+
const { useQuery } = createHooks({
|
|
506
|
+
baseUrl: 'https://example.com/api',
|
|
507
|
+
fetchFn: fetchMock,
|
|
508
|
+
headers: {
|
|
509
|
+
'Authorization': 'Bearer token123',
|
|
510
|
+
'X-Custom': 'value',
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
514
|
+
wrapper: createWrapper(),
|
|
515
|
+
});
|
|
516
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
517
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
518
|
+
headers: expect.objectContaining({
|
|
519
|
+
'Authorization': 'Bearer token123',
|
|
520
|
+
'X-Custom': 'value',
|
|
521
|
+
}),
|
|
522
|
+
}));
|
|
523
|
+
});
|
|
524
|
+
it('handles baseUrl with trailing slash', async () => {
|
|
525
|
+
const { useQuery } = createHooks({
|
|
526
|
+
baseUrl: 'https://example.com/api/',
|
|
527
|
+
fetchFn: fetchMock,
|
|
528
|
+
});
|
|
529
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
530
|
+
wrapper: createWrapper(),
|
|
531
|
+
});
|
|
532
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
533
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
534
|
+
expect(callUrl).toContain('https://example.com/api/getUser');
|
|
535
|
+
expect(callUrl).not.toContain('//getUser');
|
|
536
|
+
});
|
|
537
|
+
it('handles baseUrl without trailing slash', async () => {
|
|
538
|
+
const { useQuery } = createHooks({
|
|
539
|
+
baseUrl: 'https://example.com/api',
|
|
540
|
+
fetchFn: fetchMock,
|
|
541
|
+
});
|
|
542
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
543
|
+
wrapper: createWrapper(),
|
|
544
|
+
});
|
|
545
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
546
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
547
|
+
expect(callUrl).toContain('https://example.com/api/getUser');
|
|
548
|
+
});
|
|
549
|
+
it('throws error when baseUrl is missing', () => {
|
|
550
|
+
expect(() => {
|
|
551
|
+
createHooks({
|
|
552
|
+
baseUrl: '',
|
|
553
|
+
fetchFn: fetchMock,
|
|
554
|
+
});
|
|
555
|
+
}).not.toThrow();
|
|
556
|
+
const { useQuery } = createHooks({
|
|
557
|
+
baseUrl: '',
|
|
558
|
+
fetchFn: fetchMock,
|
|
559
|
+
});
|
|
560
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
561
|
+
wrapper: createWrapper(),
|
|
562
|
+
});
|
|
563
|
+
// Should throw during query execution
|
|
564
|
+
waitFor(() => {
|
|
565
|
+
expect(result.current.error?.message).toContain('baseUrl is required');
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
describe('Edge Cases', () => {
|
|
570
|
+
const fetchMock = vi.fn();
|
|
571
|
+
beforeEach(() => {
|
|
572
|
+
fetchMock.mockReset();
|
|
573
|
+
});
|
|
574
|
+
it('handles empty response body', async () => {
|
|
575
|
+
fetchMock.mockResolvedValue({
|
|
576
|
+
ok: true,
|
|
577
|
+
status: 204,
|
|
578
|
+
json: () => Promise.resolve(null),
|
|
579
|
+
});
|
|
580
|
+
const { useQuery } = createHooks({
|
|
581
|
+
baseUrl: 'https://example.com/api',
|
|
582
|
+
fetchFn: fetchMock,
|
|
583
|
+
});
|
|
584
|
+
const { result } = renderHook(() => useQuery('noInput'), {
|
|
585
|
+
wrapper: createWrapper(),
|
|
586
|
+
});
|
|
587
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
588
|
+
expect(result.current.data).toBe(null);
|
|
589
|
+
});
|
|
590
|
+
it('handles non-JSON text responses', async () => {
|
|
591
|
+
fetchMock.mockResolvedValue({
|
|
592
|
+
ok: true,
|
|
593
|
+
status: 200,
|
|
594
|
+
json: () => Promise.reject(new Error('Not JSON')),
|
|
595
|
+
text: () => Promise.resolve('Plain text response'),
|
|
596
|
+
});
|
|
597
|
+
const { useQuery } = createHooks({
|
|
598
|
+
baseUrl: 'https://example.com/api',
|
|
599
|
+
fetchFn: fetchMock,
|
|
600
|
+
});
|
|
601
|
+
const { result } = renderHook(() => useQuery('noInput'), {
|
|
602
|
+
wrapper: createWrapper(),
|
|
603
|
+
});
|
|
604
|
+
// This will fail because json() rejects, but shows the edge case
|
|
605
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
606
|
+
});
|
|
607
|
+
it('passes through TanStack Query options correctly', async () => {
|
|
608
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
|
|
609
|
+
const { useQuery } = createHooks({
|
|
610
|
+
baseUrl: 'https://example.com/api',
|
|
611
|
+
fetchFn: fetchMock,
|
|
612
|
+
});
|
|
613
|
+
const { result, rerender } = renderHook(({ enabled }) => useQuery('noInput', { enabled, staleTime: 1000 }), {
|
|
614
|
+
wrapper: createWrapper(),
|
|
615
|
+
initialProps: { enabled: false },
|
|
616
|
+
});
|
|
617
|
+
// Should not fetch when disabled (now requires 2+ option keys to be recognized)
|
|
618
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
619
|
+
// Enable the query
|
|
620
|
+
rerender({ enabled: true });
|
|
621
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
622
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
describe('useMutation', () => {
|
|
626
|
+
const fetchMock = vi.fn();
|
|
627
|
+
beforeEach(() => {
|
|
628
|
+
fetchMock.mockReset();
|
|
629
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
630
|
+
});
|
|
631
|
+
it('defaults to POST method', async () => {
|
|
632
|
+
const { useMutation } = createHooks({
|
|
633
|
+
baseUrl: 'https://example.com/api',
|
|
634
|
+
fetchFn: fetchMock,
|
|
635
|
+
});
|
|
636
|
+
const { result } = renderHook(() => useMutation('updateUser'), {
|
|
637
|
+
wrapper: createWrapper(),
|
|
638
|
+
});
|
|
639
|
+
await result.current.mutateAsync({ id: '123', name: 'John' });
|
|
640
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/updateUser', expect.objectContaining({
|
|
641
|
+
method: 'POST',
|
|
642
|
+
body: JSON.stringify({ id: '123', name: 'John' }),
|
|
643
|
+
}));
|
|
644
|
+
});
|
|
645
|
+
it('allows custom HTTP methods via config', async () => {
|
|
646
|
+
const { useMutation } = createHooks({
|
|
647
|
+
baseUrl: 'https://example.com/api',
|
|
648
|
+
fetchFn: fetchMock,
|
|
649
|
+
config: {
|
|
650
|
+
updateUser: { method: 'PATCH' },
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
const { result } = renderHook(() => useMutation('updateUser'), {
|
|
654
|
+
wrapper: createWrapper(),
|
|
655
|
+
});
|
|
656
|
+
await result.current.mutateAsync({ id: '123', name: 'John' });
|
|
657
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/updateUser', expect.objectContaining({
|
|
658
|
+
method: 'PATCH',
|
|
659
|
+
body: JSON.stringify({ id: '123', name: 'John' }),
|
|
660
|
+
}));
|
|
661
|
+
});
|
|
662
|
+
it('passes through mutation options', async () => {
|
|
663
|
+
const onSuccess = vi.fn();
|
|
664
|
+
const onError = vi.fn();
|
|
665
|
+
const { useMutation } = createHooks({
|
|
666
|
+
baseUrl: 'https://example.com/api',
|
|
667
|
+
fetchFn: fetchMock,
|
|
668
|
+
});
|
|
669
|
+
const { result } = renderHook(() => useMutation('updateUser', { onSuccess, onError }), { wrapper: createWrapper() });
|
|
670
|
+
await result.current.mutateAsync({ id: '123', name: 'John' });
|
|
671
|
+
expect(onSuccess).toHaveBeenCalled();
|
|
672
|
+
expect(onError).not.toHaveBeenCalled();
|
|
673
|
+
});
|
|
674
|
+
it('handles mutation errors', async () => {
|
|
675
|
+
fetchMock.mockResolvedValue(mockErrorResponse(400, { error: 'Invalid data' }));
|
|
676
|
+
const { useMutation } = createHooks({
|
|
677
|
+
baseUrl: 'https://example.com/api',
|
|
678
|
+
fetchFn: fetchMock,
|
|
679
|
+
});
|
|
680
|
+
const { result } = renderHook(() => useMutation('updateUser'), {
|
|
681
|
+
wrapper: createWrapper(),
|
|
682
|
+
});
|
|
683
|
+
await expect(result.current.mutateAsync({ id: '123', name: 'John' })).rejects.toThrow();
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
});
|