@hypequery/react 0.0.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.
- package/LICENSE +201 -0
- package/README.md +365 -0
- package/dist/createHooks.d.ts +45 -0
- package/dist/createHooks.d.ts.map +1 -0
- package/dist/createHooks.js +178 -0
- package/dist/createHooks.test.d.ts +2 -0
- package/dist/createHooks.test.d.ts.map +1 -0
- package/dist/createHooks.test.js +550 -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 +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -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 +39 -0
|
@@ -0,0 +1,550 @@
|
|
|
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
|
+
});
|
|
63
|
+
describe('HTTP Method Handling', () => {
|
|
64
|
+
const fetchMock = vi.fn();
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
fetchMock.mockReset();
|
|
67
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
68
|
+
});
|
|
69
|
+
it('defaults to GET when no config provided', async () => {
|
|
70
|
+
const { useQuery } = createHooks({
|
|
71
|
+
baseUrl: 'https://example.com/api',
|
|
72
|
+
fetchFn: fetchMock,
|
|
73
|
+
});
|
|
74
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
75
|
+
wrapper: createWrapper(),
|
|
76
|
+
});
|
|
77
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
78
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('?'), expect.objectContaining({ method: 'GET' }));
|
|
79
|
+
});
|
|
80
|
+
it('uses POST when explicitly configured', async () => {
|
|
81
|
+
const { useQuery } = createHooks({
|
|
82
|
+
baseUrl: 'https://example.com/api',
|
|
83
|
+
fetchFn: fetchMock,
|
|
84
|
+
config: {
|
|
85
|
+
getUser: { method: 'POST' },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
89
|
+
wrapper: createWrapper(),
|
|
90
|
+
});
|
|
91
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
92
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({
|
|
93
|
+
method: 'POST',
|
|
94
|
+
body: JSON.stringify({ id: '123' }),
|
|
95
|
+
}));
|
|
96
|
+
});
|
|
97
|
+
it('extracts method config from API object', async () => {
|
|
98
|
+
const mockApi = {
|
|
99
|
+
queries: {
|
|
100
|
+
getUser: { method: 'GET' },
|
|
101
|
+
updateUser: { method: 'PATCH' },
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const { useQuery } = createHooks({
|
|
105
|
+
baseUrl: 'https://example.com/api',
|
|
106
|
+
fetchFn: fetchMock,
|
|
107
|
+
api: mockApi,
|
|
108
|
+
});
|
|
109
|
+
const { result } = renderHook(() => useQuery('updateUser', { id: '123', name: 'John' }), {
|
|
110
|
+
wrapper: createWrapper(),
|
|
111
|
+
});
|
|
112
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
113
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'PATCH' }));
|
|
114
|
+
});
|
|
115
|
+
it('prefers route-level config over endpoint config', async () => {
|
|
116
|
+
const mockApi = {
|
|
117
|
+
queries: {
|
|
118
|
+
getUser: { method: 'POST' },
|
|
119
|
+
},
|
|
120
|
+
_routeConfig: {
|
|
121
|
+
getUser: { method: 'GET' },
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const { useQuery } = createHooks({
|
|
125
|
+
baseUrl: 'https://example.com/api',
|
|
126
|
+
fetchFn: fetchMock,
|
|
127
|
+
api: mockApi,
|
|
128
|
+
});
|
|
129
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
130
|
+
wrapper: createWrapper(),
|
|
131
|
+
});
|
|
132
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
133
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'GET' }));
|
|
134
|
+
});
|
|
135
|
+
it('allows explicit config to override API config', async () => {
|
|
136
|
+
const mockApi = {
|
|
137
|
+
queries: {
|
|
138
|
+
getUser: { method: 'GET' },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const { useQuery } = createHooks({
|
|
142
|
+
baseUrl: 'https://example.com/api',
|
|
143
|
+
fetchFn: fetchMock,
|
|
144
|
+
api: mockApi,
|
|
145
|
+
config: {
|
|
146
|
+
getUser: { method: 'POST' },
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
150
|
+
wrapper: createWrapper(),
|
|
151
|
+
});
|
|
152
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
153
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({ method: 'POST' }));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('Query Parameter Serialization', () => {
|
|
157
|
+
const fetchMock = vi.fn();
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
fetchMock.mockReset();
|
|
160
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
161
|
+
});
|
|
162
|
+
it('serializes simple objects as query params for GET requests', async () => {
|
|
163
|
+
const { useQuery } = createHooks({
|
|
164
|
+
baseUrl: 'https://example.com/api',
|
|
165
|
+
fetchFn: fetchMock,
|
|
166
|
+
});
|
|
167
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
168
|
+
wrapper: createWrapper(),
|
|
169
|
+
});
|
|
170
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
171
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser?id=123', expect.objectContaining({ method: 'GET' }));
|
|
172
|
+
});
|
|
173
|
+
it('serializes arrays as multiple query params', async () => {
|
|
174
|
+
const { useQuery } = createHooks({
|
|
175
|
+
baseUrl: 'https://example.com/api',
|
|
176
|
+
fetchFn: fetchMock,
|
|
177
|
+
});
|
|
178
|
+
const { result } = renderHook(() => useQuery('listItems', { tags: ['react', 'typescript'], limit: 10 }), { wrapper: createWrapper() });
|
|
179
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
180
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
181
|
+
expect(callUrl).toContain('tags=react');
|
|
182
|
+
expect(callUrl).toContain('tags=typescript');
|
|
183
|
+
expect(callUrl).toContain('limit=10');
|
|
184
|
+
});
|
|
185
|
+
it('skips undefined and null values in query params', async () => {
|
|
186
|
+
const { useQuery } = createHooks({
|
|
187
|
+
baseUrl: 'https://example.com/api',
|
|
188
|
+
fetchFn: fetchMock,
|
|
189
|
+
});
|
|
190
|
+
const { result } = renderHook(() => useQuery('rebuildMetrics', { force: undefined }), { wrapper: createWrapper() });
|
|
191
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
192
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
193
|
+
expect(callUrl).not.toContain('force');
|
|
194
|
+
});
|
|
195
|
+
it('handles empty objects in GET requests', async () => {
|
|
196
|
+
const { useQuery } = createHooks({
|
|
197
|
+
baseUrl: 'https://example.com/api',
|
|
198
|
+
fetchFn: fetchMock,
|
|
199
|
+
});
|
|
200
|
+
const { result } = renderHook(() => useQuery('rebuildMetrics', {}), { wrapper: createWrapper() });
|
|
201
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
202
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/rebuildMetrics', expect.objectContaining({ method: 'GET' }));
|
|
203
|
+
});
|
|
204
|
+
it('uses JSON body for POST requests', async () => {
|
|
205
|
+
const { useQuery } = createHooks({
|
|
206
|
+
baseUrl: 'https://example.com/api',
|
|
207
|
+
fetchFn: fetchMock,
|
|
208
|
+
config: {
|
|
209
|
+
getUser: { method: 'POST' },
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
213
|
+
wrapper: createWrapper(),
|
|
214
|
+
});
|
|
215
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
216
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({
|
|
217
|
+
method: 'POST',
|
|
218
|
+
body: JSON.stringify({ id: '123' }),
|
|
219
|
+
headers: expect.objectContaining({
|
|
220
|
+
'content-type': 'application/json',
|
|
221
|
+
}),
|
|
222
|
+
}));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
describe('Error Handling', () => {
|
|
226
|
+
const fetchMock = vi.fn();
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
fetchMock.mockReset();
|
|
229
|
+
});
|
|
230
|
+
it('throws error with detailed message on failed request', async () => {
|
|
231
|
+
fetchMock.mockResolvedValue(mockErrorResponse(404, { message: 'Not found' }));
|
|
232
|
+
const { useQuery } = createHooks({
|
|
233
|
+
baseUrl: 'https://example.com/api',
|
|
234
|
+
fetchFn: fetchMock,
|
|
235
|
+
});
|
|
236
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
237
|
+
wrapper: createWrapper(),
|
|
238
|
+
});
|
|
239
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
240
|
+
expect(result.current.error).toBeInstanceOf(HttpError);
|
|
241
|
+
expect(result.current.error?.message).toContain('GET request to');
|
|
242
|
+
expect(result.current.error?.message).toContain('failed with status 404');
|
|
243
|
+
expect(result.current.error?.status).toBe(404);
|
|
244
|
+
expect(result.current.error?.body).toEqual({ message: 'Not found' });
|
|
245
|
+
});
|
|
246
|
+
it('parses JSON error responses', async () => {
|
|
247
|
+
fetchMock.mockResolvedValue(mockErrorResponse(400, { error: 'Bad request' }));
|
|
248
|
+
const { useQuery } = createHooks({
|
|
249
|
+
baseUrl: 'https://example.com/api',
|
|
250
|
+
fetchFn: fetchMock,
|
|
251
|
+
});
|
|
252
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
253
|
+
wrapper: createWrapper(),
|
|
254
|
+
});
|
|
255
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
256
|
+
expect(result.current.error).toBeInstanceOf(HttpError);
|
|
257
|
+
expect(result.current.error?.body).toEqual({ error: 'Bad request' });
|
|
258
|
+
});
|
|
259
|
+
it('parses text error responses', async () => {
|
|
260
|
+
fetchMock.mockResolvedValue({
|
|
261
|
+
ok: false,
|
|
262
|
+
status: 500,
|
|
263
|
+
text: () => Promise.resolve('Internal server error'),
|
|
264
|
+
});
|
|
265
|
+
const { useQuery } = createHooks({
|
|
266
|
+
baseUrl: 'https://example.com/api',
|
|
267
|
+
fetchFn: fetchMock,
|
|
268
|
+
});
|
|
269
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
270
|
+
wrapper: createWrapper(),
|
|
271
|
+
});
|
|
272
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
273
|
+
expect(result.current.error).toBeInstanceOf(HttpError);
|
|
274
|
+
expect(result.current.error?.body).toBe('Internal server error');
|
|
275
|
+
});
|
|
276
|
+
it('handles network errors', async () => {
|
|
277
|
+
fetchMock.mockRejectedValue(new Error('Network error'));
|
|
278
|
+
const { useQuery } = createHooks({
|
|
279
|
+
baseUrl: 'https://example.com/api',
|
|
280
|
+
fetchFn: fetchMock,
|
|
281
|
+
});
|
|
282
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
283
|
+
wrapper: createWrapper(),
|
|
284
|
+
});
|
|
285
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
286
|
+
expect(result.current.error?.message).toBe('Network error');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
describe('useQuery Argument Overloads', () => {
|
|
290
|
+
const fetchMock = vi.fn();
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
fetchMock.mockReset();
|
|
293
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
|
|
294
|
+
});
|
|
295
|
+
it('handles useQuery(name) with no input', async () => {
|
|
296
|
+
const { useQuery } = createHooks({
|
|
297
|
+
baseUrl: 'https://example.com/api',
|
|
298
|
+
fetchFn: fetchMock,
|
|
299
|
+
});
|
|
300
|
+
const { result } = renderHook(() => useQuery('noInput'), {
|
|
301
|
+
wrapper: createWrapper(),
|
|
302
|
+
});
|
|
303
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
304
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/noInput', expect.any(Object));
|
|
305
|
+
});
|
|
306
|
+
it('handles useQuery(name, input)', async () => {
|
|
307
|
+
const { useQuery } = createHooks({
|
|
308
|
+
baseUrl: 'https://example.com/api',
|
|
309
|
+
fetchFn: fetchMock,
|
|
310
|
+
});
|
|
311
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
312
|
+
wrapper: createWrapper(),
|
|
313
|
+
});
|
|
314
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
315
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
it('handles useQuery(name, options) - options only', async () => {
|
|
318
|
+
const { useQuery } = createHooks({
|
|
319
|
+
baseUrl: 'https://example.com/api',
|
|
320
|
+
fetchFn: fetchMock,
|
|
321
|
+
});
|
|
322
|
+
const { result } = renderHook(() => useQuery('noInput', { enabled: false, staleTime: 5000 }), { wrapper: createWrapper() });
|
|
323
|
+
// Should not fetch because enabled: false
|
|
324
|
+
expect(result.current.isSuccess).toBe(false);
|
|
325
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
it('handles useQuery(name, input, options)', async () => {
|
|
328
|
+
const { useQuery } = createHooks({
|
|
329
|
+
baseUrl: 'https://example.com/api',
|
|
330
|
+
fetchFn: fetchMock,
|
|
331
|
+
});
|
|
332
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }, { staleTime: 5000 }), { wrapper: createWrapper() });
|
|
333
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
334
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
it('correctly distinguishes input from options using isQueryOptions', async () => {
|
|
337
|
+
const { useQuery } = createHooks({
|
|
338
|
+
baseUrl: 'https://example.com/api',
|
|
339
|
+
fetchFn: fetchMock,
|
|
340
|
+
});
|
|
341
|
+
// Input with 'enabled' field should be treated as input, not options
|
|
342
|
+
const { result: result1 } = renderHook(() => useQuery('rebuildMetrics', { force: true }), { wrapper: createWrapper() });
|
|
343
|
+
await waitFor(() => expect(result1.current.isSuccess).toBe(true));
|
|
344
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
345
|
+
fetchMock.mockClear();
|
|
346
|
+
// Options object should disable the query
|
|
347
|
+
const { result: result2 } = renderHook(() => useQuery('noInput', { enabled: false, staleTime: 1000 }), { wrapper: createWrapper() });
|
|
348
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
349
|
+
});
|
|
350
|
+
it('uses queryOptions() helper for explicit option marking', async () => {
|
|
351
|
+
const { useQuery } = createHooks({
|
|
352
|
+
baseUrl: 'https://example.com/api',
|
|
353
|
+
fetchFn: fetchMock,
|
|
354
|
+
});
|
|
355
|
+
// Using queryOptions() helper ensures options are recognized
|
|
356
|
+
const { result } = renderHook(() => useQuery('noInput', queryOptions({ enabled: false })), { wrapper: createWrapper() });
|
|
357
|
+
// Query should be disabled
|
|
358
|
+
expect(result.current.isLoading).toBe(false);
|
|
359
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
describe('Configuration', () => {
|
|
363
|
+
const fetchMock = vi.fn();
|
|
364
|
+
beforeEach(() => {
|
|
365
|
+
fetchMock.mockReset();
|
|
366
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
367
|
+
});
|
|
368
|
+
it('includes custom headers in requests', async () => {
|
|
369
|
+
const { useQuery } = createHooks({
|
|
370
|
+
baseUrl: 'https://example.com/api',
|
|
371
|
+
fetchFn: fetchMock,
|
|
372
|
+
headers: {
|
|
373
|
+
'Authorization': 'Bearer token123',
|
|
374
|
+
'X-Custom': 'value',
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
378
|
+
wrapper: createWrapper(),
|
|
379
|
+
});
|
|
380
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
381
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
382
|
+
headers: expect.objectContaining({
|
|
383
|
+
'Authorization': 'Bearer token123',
|
|
384
|
+
'X-Custom': 'value',
|
|
385
|
+
}),
|
|
386
|
+
}));
|
|
387
|
+
});
|
|
388
|
+
it('handles baseUrl with trailing slash', async () => {
|
|
389
|
+
const { useQuery } = createHooks({
|
|
390
|
+
baseUrl: 'https://example.com/api/',
|
|
391
|
+
fetchFn: fetchMock,
|
|
392
|
+
});
|
|
393
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
394
|
+
wrapper: createWrapper(),
|
|
395
|
+
});
|
|
396
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
397
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
398
|
+
expect(callUrl).toContain('https://example.com/api/getUser');
|
|
399
|
+
expect(callUrl).not.toContain('//getUser');
|
|
400
|
+
});
|
|
401
|
+
it('handles baseUrl without trailing slash', async () => {
|
|
402
|
+
const { useQuery } = createHooks({
|
|
403
|
+
baseUrl: 'https://example.com/api',
|
|
404
|
+
fetchFn: fetchMock,
|
|
405
|
+
});
|
|
406
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
407
|
+
wrapper: createWrapper(),
|
|
408
|
+
});
|
|
409
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
410
|
+
const callUrl = fetchMock.mock.calls[0][0];
|
|
411
|
+
expect(callUrl).toContain('https://example.com/api/getUser');
|
|
412
|
+
});
|
|
413
|
+
it('throws error when baseUrl is missing', () => {
|
|
414
|
+
expect(() => {
|
|
415
|
+
createHooks({
|
|
416
|
+
baseUrl: '',
|
|
417
|
+
fetchFn: fetchMock,
|
|
418
|
+
});
|
|
419
|
+
}).not.toThrow();
|
|
420
|
+
const { useQuery } = createHooks({
|
|
421
|
+
baseUrl: '',
|
|
422
|
+
fetchFn: fetchMock,
|
|
423
|
+
});
|
|
424
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
425
|
+
wrapper: createWrapper(),
|
|
426
|
+
});
|
|
427
|
+
// Should throw during query execution
|
|
428
|
+
waitFor(() => {
|
|
429
|
+
expect(result.current.error?.message).toContain('baseUrl is required');
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
describe('Edge Cases', () => {
|
|
434
|
+
const fetchMock = vi.fn();
|
|
435
|
+
beforeEach(() => {
|
|
436
|
+
fetchMock.mockReset();
|
|
437
|
+
});
|
|
438
|
+
it('handles empty response body', async () => {
|
|
439
|
+
fetchMock.mockResolvedValue({
|
|
440
|
+
ok: true,
|
|
441
|
+
status: 204,
|
|
442
|
+
json: () => Promise.resolve(null),
|
|
443
|
+
});
|
|
444
|
+
const { useQuery } = createHooks({
|
|
445
|
+
baseUrl: 'https://example.com/api',
|
|
446
|
+
fetchFn: fetchMock,
|
|
447
|
+
});
|
|
448
|
+
const { result } = renderHook(() => useQuery('noInput'), {
|
|
449
|
+
wrapper: createWrapper(),
|
|
450
|
+
});
|
|
451
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
452
|
+
expect(result.current.data).toBe(null);
|
|
453
|
+
});
|
|
454
|
+
it('handles non-JSON text responses', async () => {
|
|
455
|
+
fetchMock.mockResolvedValue({
|
|
456
|
+
ok: true,
|
|
457
|
+
status: 200,
|
|
458
|
+
json: () => Promise.reject(new Error('Not JSON')),
|
|
459
|
+
text: () => Promise.resolve('Plain text response'),
|
|
460
|
+
});
|
|
461
|
+
const { useQuery } = createHooks({
|
|
462
|
+
baseUrl: 'https://example.com/api',
|
|
463
|
+
fetchFn: fetchMock,
|
|
464
|
+
});
|
|
465
|
+
const { result } = renderHook(() => useQuery('noInput'), {
|
|
466
|
+
wrapper: createWrapper(),
|
|
467
|
+
});
|
|
468
|
+
// This will fail because json() rejects, but shows the edge case
|
|
469
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
470
|
+
});
|
|
471
|
+
it('passes through TanStack Query options correctly', async () => {
|
|
472
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
|
|
473
|
+
const { useQuery } = createHooks({
|
|
474
|
+
baseUrl: 'https://example.com/api',
|
|
475
|
+
fetchFn: fetchMock,
|
|
476
|
+
});
|
|
477
|
+
const { result, rerender } = renderHook(({ enabled }) => useQuery('noInput', { enabled, staleTime: 1000 }), {
|
|
478
|
+
wrapper: createWrapper(),
|
|
479
|
+
initialProps: { enabled: false },
|
|
480
|
+
});
|
|
481
|
+
// Should not fetch when disabled (now requires 2+ option keys to be recognized)
|
|
482
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
483
|
+
// Enable the query
|
|
484
|
+
rerender({ enabled: true });
|
|
485
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
486
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
describe('useMutation', () => {
|
|
490
|
+
const fetchMock = vi.fn();
|
|
491
|
+
beforeEach(() => {
|
|
492
|
+
fetchMock.mockReset();
|
|
493
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
494
|
+
});
|
|
495
|
+
it('defaults to POST method', async () => {
|
|
496
|
+
const { useMutation } = createHooks({
|
|
497
|
+
baseUrl: 'https://example.com/api',
|
|
498
|
+
fetchFn: fetchMock,
|
|
499
|
+
});
|
|
500
|
+
const { result } = renderHook(() => useMutation('updateUser'), {
|
|
501
|
+
wrapper: createWrapper(),
|
|
502
|
+
});
|
|
503
|
+
await result.current.mutateAsync({ id: '123', name: 'John' });
|
|
504
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/updateUser', expect.objectContaining({
|
|
505
|
+
method: 'POST',
|
|
506
|
+
body: JSON.stringify({ id: '123', name: 'John' }),
|
|
507
|
+
}));
|
|
508
|
+
});
|
|
509
|
+
it('allows custom HTTP methods via config', async () => {
|
|
510
|
+
const { useMutation } = createHooks({
|
|
511
|
+
baseUrl: 'https://example.com/api',
|
|
512
|
+
fetchFn: fetchMock,
|
|
513
|
+
config: {
|
|
514
|
+
updateUser: { method: 'PATCH' },
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
const { result } = renderHook(() => useMutation('updateUser'), {
|
|
518
|
+
wrapper: createWrapper(),
|
|
519
|
+
});
|
|
520
|
+
await result.current.mutateAsync({ id: '123', name: 'John' });
|
|
521
|
+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/updateUser', expect.objectContaining({
|
|
522
|
+
method: 'PATCH',
|
|
523
|
+
body: JSON.stringify({ id: '123', name: 'John' }),
|
|
524
|
+
}));
|
|
525
|
+
});
|
|
526
|
+
it('passes through mutation options', async () => {
|
|
527
|
+
const onSuccess = vi.fn();
|
|
528
|
+
const onError = vi.fn();
|
|
529
|
+
const { useMutation } = createHooks({
|
|
530
|
+
baseUrl: 'https://example.com/api',
|
|
531
|
+
fetchFn: fetchMock,
|
|
532
|
+
});
|
|
533
|
+
const { result } = renderHook(() => useMutation('updateUser', { onSuccess, onError }), { wrapper: createWrapper() });
|
|
534
|
+
await result.current.mutateAsync({ id: '123', name: 'John' });
|
|
535
|
+
expect(onSuccess).toHaveBeenCalled();
|
|
536
|
+
expect(onError).not.toHaveBeenCalled();
|
|
537
|
+
});
|
|
538
|
+
it('handles mutation errors', async () => {
|
|
539
|
+
fetchMock.mockResolvedValue(mockErrorResponse(400, { error: 'Invalid data' }));
|
|
540
|
+
const { useMutation } = createHooks({
|
|
541
|
+
baseUrl: 'https://example.com/api',
|
|
542
|
+
fetchFn: fetchMock,
|
|
543
|
+
});
|
|
544
|
+
const { result } = renderHook(() => useMutation('updateUser'), {
|
|
545
|
+
wrapper: createWrapper(),
|
|
546
|
+
});
|
|
547
|
+
await expect(result.current.mutateAsync({ id: '123', name: 'John' })).rejects.toThrow();
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
});
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP error with status code and response body
|
|
3
|
+
*/
|
|
4
|
+
export declare class HttpError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly body: unknown;
|
|
7
|
+
constructor(message: string, status: number, body: unknown);
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,SAAU,SAAQ,KAAK;aAGhB,MAAM,EAAE,MAAM;aACd,IAAI,EAAE,OAAO;gBAF7B,OAAO,EAAE,MAAM,EACC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO;CAOhC"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP error with status code and response body
|
|
3
|
+
*/
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
constructor(message, status, body) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.body = body;
|
|
9
|
+
this.name = 'HttpError';
|
|
10
|
+
// Maintain proper prototype chain for instanceof checks
|
|
11
|
+
Object.setPrototypeOf(this, HttpError.prototype);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createHooks, queryOptions } from './createHooks.js';
|
|
2
|
+
export { HttpError } from './errors.js';
|
|
3
|
+
export type { QueryInput, QueryOutput, HttpMethod } from './types.js';
|
|
4
|
+
export type { CreateHooksConfig, QueryMethodConfig } from './createHooks.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
2
|
+
export type ExtractNames<Api> = Extract<keyof Api, string>;
|
|
3
|
+
export type QueryInput<Api, Name extends ExtractNames<Api>> = Api[Name] extends {
|
|
4
|
+
input: infer Input;
|
|
5
|
+
} ? Input : never;
|
|
6
|
+
export type QueryOutput<Api, Name extends ExtractNames<Api>> = Api[Name] extends {
|
|
7
|
+
output: infer Output;
|
|
8
|
+
} ? Output : never;
|
|
9
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAErE,MAAM,MAAM,YAAY,CAAC,GAAG,IAAI,OAAO,CAAC,MAAM,GAAG,EAAE,MAAM,CAAC,CAAC;AAE3D,MAAM,MAAM,UAAU,CACpB,GAAG,EACH,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,IAC5B,GAAG,CAAC,IAAI,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,KAAK,CAAA;CAAE,GAAG,KAAK,GAAG,KAAK,CAAC;AAE7D,MAAM,MAAM,WAAW,CACrB,GAAG,EACH,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,IAC5B,GAAG,CAAC,IAAI,CAAC,SAAS;IAAE,MAAM,EAAE,MAAM,MAAM,CAAA;CAAE,GAAG,MAAM,GAAG,KAAK,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hypequery/react",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "React hooks for consuming hypequery APIs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@tanstack/react-query": "^5.0.0",
|
|
18
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
19
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@tanstack/react-query": "^5.0.0",
|
|
23
|
+
"@testing-library/react": "^14.2.1",
|
|
24
|
+
"@types/react": "^18.2.43",
|
|
25
|
+
"@types/react-dom": "^18.2.18",
|
|
26
|
+
"@vitest/coverage-v8": "^2.1.6",
|
|
27
|
+
"jsdom": "^27.4.0",
|
|
28
|
+
"react": "^18.2.0",
|
|
29
|
+
"react-dom": "^18.2.0",
|
|
30
|
+
"typescript": "^5.7.3",
|
|
31
|
+
"vitest": "^2.1.6"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -b",
|
|
35
|
+
"types": "tsc -b --noEmit",
|
|
36
|
+
"test:types": "tsc --project tsconfig.type-tests.json",
|
|
37
|
+
"test": "npm run test:types && vitest run"
|
|
38
|
+
}
|
|
39
|
+
}
|