zero-query 1.1.1 → 1.2.0

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.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -1,648 +1,648 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { http } from '../src/http.js';
3
-
4
-
5
- // ---------------------------------------------------------------------------
6
- // Mock fetch
7
- // ---------------------------------------------------------------------------
8
- let fetchSpy;
9
-
10
- function mockFetch(response = {}, ok = true, status = 200) {
11
- const body = typeof response === 'string' ? response : JSON.stringify(response);
12
- const contentType = typeof response === 'string' ? 'text/plain' : 'application/json';
13
-
14
- fetchSpy = vi.fn(() =>
15
- Promise.resolve({
16
- ok,
17
- status,
18
- statusText: ok ? 'OK' : 'Error',
19
- headers: {
20
- get: (h) => h.toLowerCase() === 'content-type' ? contentType : null,
21
- entries: () => [[`content-type`, contentType]],
22
- },
23
- json: () => Promise.resolve(typeof response === 'object' ? response : JSON.parse(response)),
24
- text: () => Promise.resolve(body),
25
- blob: () => Promise.resolve(new Blob([body])),
26
- })
27
- );
28
- globalThis.fetch = fetchSpy;
29
- }
30
-
31
- beforeEach(() => {
32
- http.configure({ baseURL: '', headers: { 'Content-Type': 'application/json' }, timeout: 30000 });
33
- });
34
-
35
- afterEach(() => {
36
- vi.restoreAllMocks();
37
- });
38
-
39
-
40
- // ---------------------------------------------------------------------------
41
- // GET requests
42
- // ---------------------------------------------------------------------------
43
-
44
- describe('http.get', () => {
45
- it('makes a GET request', async () => {
46
- mockFetch({ users: [] });
47
- const result = await http.get('https://api.test.com/users');
48
- expect(fetchSpy).toHaveBeenCalledOnce();
49
- expect(result.data).toEqual({ users: [] });
50
- expect(result.ok).toBe(true);
51
- expect(result.status).toBe(200);
52
- });
53
-
54
- it('appends query params for GET', async () => {
55
- mockFetch({});
56
- await http.get('https://api.test.com/search', { q: 'hello', page: '1' });
57
- const url = fetchSpy.mock.calls[0][0];
58
- expect(url).toContain('q=hello');
59
- expect(url).toContain('page=1');
60
- });
61
-
62
- it('uses baseURL', async () => {
63
- http.configure({ baseURL: 'https://api.test.com' });
64
- mockFetch({});
65
- await http.get('/users');
66
- expect(fetchSpy.mock.calls[0][0]).toBe('https://api.test.com/users');
67
- });
68
- });
69
-
70
-
71
- // ---------------------------------------------------------------------------
72
- // POST requests
73
- // ---------------------------------------------------------------------------
74
-
75
- describe('http.post', () => {
76
- it('sends JSON body', async () => {
77
- mockFetch({ id: 1 });
78
- const result = await http.post('https://api.test.com/users', { name: 'Tony' });
79
- const opts = fetchSpy.mock.calls[0][1];
80
- expect(opts.method).toBe('POST');
81
- expect(opts.body).toBe(JSON.stringify({ name: 'Tony' }));
82
- expect(result.data).toEqual({ id: 1 });
83
- });
84
-
85
- it('handles FormData body', async () => {
86
- mockFetch({});
87
- const form = new FormData();
88
- form.append('file', 'data');
89
- await http.post('https://api.test.com/upload', form);
90
- const opts = fetchSpy.mock.calls[0][1];
91
- expect(opts.body).toBe(form);
92
- // Content-Type should be removed for FormData
93
- expect(opts.headers['Content-Type']).toBeUndefined();
94
- });
95
- });
96
-
97
-
98
- // ---------------------------------------------------------------------------
99
- // Error handling
100
- // ---------------------------------------------------------------------------
101
-
102
- describe('http - error handling', () => {
103
- it('throws on non-ok response', async () => {
104
- mockFetch({ error: 'Not Found' }, false, 404);
105
- await expect(http.get('https://api.test.com/missing')).rejects.toThrow('HTTP 404');
106
- });
107
-
108
- it('error includes response data', async () => {
109
- mockFetch({ message: 'Unauthorized' }, false, 401);
110
- try {
111
- await http.get('https://api.test.com/protected');
112
- } catch (err) {
113
- expect(err.response).toBeDefined();
114
- expect(err.response.status).toBe(401);
115
- }
116
- });
117
-
118
- it('throws on invalid URL', async () => {
119
- await expect(http.get(null)).rejects.toThrow('requires a URL string');
120
- await expect(http.get(undefined)).rejects.toThrow('requires a URL string');
121
- });
122
- });
123
-
124
-
125
- // ---------------------------------------------------------------------------
126
- // PUT / PATCH / DELETE
127
- // ---------------------------------------------------------------------------
128
-
129
- describe('http - other methods', () => {
130
- it('PUT sends correct method', async () => {
131
- mockFetch({});
132
- await http.put('https://api.test.com/users/1', { name: 'Updated' });
133
- expect(fetchSpy.mock.calls[0][1].method).toBe('PUT');
134
- });
135
-
136
- it('PATCH sends correct method', async () => {
137
- mockFetch({});
138
- await http.patch('https://api.test.com/users/1', { name: 'Patched' });
139
- expect(fetchSpy.mock.calls[0][1].method).toBe('PATCH');
140
- });
141
-
142
- it('DELETE sends correct method', async () => {
143
- mockFetch({});
144
- await http.delete('https://api.test.com/users/1');
145
- expect(fetchSpy.mock.calls[0][1].method).toBe('DELETE');
146
- });
147
- });
148
-
149
-
150
- // ---------------------------------------------------------------------------
151
- // configure
152
- // ---------------------------------------------------------------------------
153
-
154
- describe('http.configure', () => {
155
- it('sets baseURL', async () => {
156
- http.configure({ baseURL: 'https://example.com/api' });
157
- mockFetch({});
158
- await http.get('/data');
159
- expect(fetchSpy.mock.calls[0][0]).toBe('https://example.com/api/data');
160
- });
161
-
162
- it('merges headers', async () => {
163
- http.configure({ headers: { Authorization: 'Bearer token' } });
164
- mockFetch({});
165
- await http.get('https://api.test.com/me');
166
- expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer token');
167
- });
168
- });
169
-
170
-
171
- // ---------------------------------------------------------------------------
172
- // Text response
173
- // ---------------------------------------------------------------------------
174
-
175
- describe('http - text response', () => {
176
- it('parses text response', async () => {
177
- mockFetch('Hello World');
178
- const result = await http.get('https://api.test.com/text');
179
- expect(result.data).toBe('Hello World');
180
- });
181
- });
182
-
183
-
184
- // ---------------------------------------------------------------------------
185
- // Interceptors
186
- // ---------------------------------------------------------------------------
187
-
188
- describe('http - interceptors', () => {
189
- it('request interceptor via onRequest', async () => {
190
- http.configure({ baseURL: '' });
191
- http.onRequest((fetchOpts, url) => {
192
- fetchOpts.headers['X-Custom'] = 'test';
193
- });
194
- mockFetch({});
195
- await http.get('https://api.test.com/data');
196
- const opts = fetchSpy.mock.calls[0][1];
197
- expect(opts.headers['X-Custom']).toBe('test');
198
- });
199
-
200
- it('response interceptor via onResponse', async () => {
201
- http.onResponse((result) => {
202
- result.intercepted = true;
203
- });
204
- mockFetch({ x: 1 });
205
- const result = await http.get('https://api.test.com/data');
206
- expect(result.intercepted).toBe(true);
207
- });
208
- });
209
-
210
-
211
- // ---------------------------------------------------------------------------
212
- // Timeout / abort
213
- // ---------------------------------------------------------------------------
214
-
215
- describe('http - abort signal', () => {
216
- it('passes signal through options', async () => {
217
- const controller = new AbortController();
218
- mockFetch({});
219
- await http.get('https://api.test.com/data', null, { signal: controller.signal });
220
- const opts = fetchSpy.mock.calls[0][1];
221
- // Signal may be combined via AbortSignal.any - verify it responds to user abort
222
- expect(opts.signal).toBeDefined();
223
- expect(opts.signal.aborted).toBe(false);
224
- controller.abort();
225
- expect(opts.signal.aborted).toBe(true);
226
- });
227
- });
228
-
229
-
230
- // ---------------------------------------------------------------------------
231
- // Blob response
232
- // ---------------------------------------------------------------------------
233
-
234
- describe('http - blob response', () => {
235
- it('can request blob responses', async () => {
236
- mockFetch('binary data');
237
- const result = await http.get('https://api.test.com/file', null, { responseType: 'blob' });
238
- // Should have a data field (blob or fallback text)
239
- expect(result.data).toBeDefined();
240
- });
241
- });
242
-
243
-
244
- // ---------------------------------------------------------------------------
245
- // HEAD and OPTIONS methods
246
- // ---------------------------------------------------------------------------
247
-
248
- describe('http - raw fetch pass-through', () => {
249
- it('raw() delegates to native fetch', async () => {
250
- mockFetch({ ok: true });
251
- await http.raw('https://api.test.com/ping', { method: 'HEAD' });
252
- expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com/ping', { method: 'HEAD' });
253
- });
254
- });
255
-
256
-
257
- // ---------------------------------------------------------------------------
258
- // Request with custom headers
259
- // ---------------------------------------------------------------------------
260
-
261
- describe('http - custom per-request headers', () => {
262
- it('merges per-request headers', async () => {
263
- mockFetch({});
264
- await http.get('https://api.test.com/data', null, {
265
- headers: { 'X-Request-Id': '123' },
266
- });
267
- const opts = fetchSpy.mock.calls[0][1];
268
- expect(opts.headers['X-Request-Id']).toBe('123');
269
- });
270
- });
271
-
272
-
273
- // ---------------------------------------------------------------------------
274
- // Response metadata
275
- // ---------------------------------------------------------------------------
276
-
277
- describe('http - response metadata', () => {
278
- it('includes status and ok in result', async () => {
279
- mockFetch({ data: 'yes' }, true, 200);
280
- const result = await http.get('https://api.test.com/data');
281
- expect(result.ok).toBe(true);
282
- expect(result.status).toBe(200);
283
- });
284
-
285
- it('includes statusText in error', async () => {
286
- mockFetch({ error: 'bad' }, false, 500);
287
- try {
288
- await http.get('https://api.test.com/fail');
289
- } catch (err) {
290
- expect(err.message).toContain('500');
291
- }
292
- });
293
- });
294
-
295
-
296
- // ===========================================================================
297
- // configure()
298
- // ===========================================================================
299
-
300
- describe('http.configure', () => {
301
- it('sets baseURL', async () => {
302
- http.configure({ baseURL: 'https://api.myapp.com' });
303
- mockFetch({ ok: true });
304
- await http.get('/users');
305
- expect(fetchSpy.mock.calls[0][0]).toBe('https://api.myapp.com/users');
306
- });
307
-
308
- it('merges headers', async () => {
309
- http.configure({ headers: { Authorization: 'Bearer token123' } });
310
- mockFetch({ ok: true });
311
- await http.get('https://api.test.com/me');
312
- const opts = fetchSpy.mock.calls[0][1];
313
- expect(opts.headers['Authorization']).toBe('Bearer token123');
314
- expect(opts.headers['Content-Type']).toBe('application/json');
315
- });
316
-
317
- it('sets timeout', () => {
318
- http.configure({ timeout: 5000 });
319
- // Just verifying it doesn't throw
320
- expect(true).toBe(true);
321
- });
322
- });
323
-
324
-
325
- // ===========================================================================
326
- // interceptors
327
- // ===========================================================================
328
-
329
- describe('http.onRequest - extra', () => {
330
- it('interceptor modifying URL', async () => {
331
- // Note: interceptors from earlier tests may still be registered; this one
332
- // only adds a query param so it doesn't break others
333
- http.onRequest((opts, url) => ({ url: url + '?token=abc', options: opts }));
334
- mockFetch({ ok: true });
335
- await http.get('https://api.test.com/data');
336
- expect(fetchSpy.mock.calls[0][0]).toContain('?token=abc');
337
- });
338
- });
339
-
340
- // Test interceptor blocking in isolation - it permanently modifies internal state,
341
- // so we verify behavior without adding a blocking interceptor that breaks later tests
342
- describe('http - interceptor blocking concept', () => {
343
- it('onRequest returning false would throw blocked error', () => {
344
- // Just verify the error message format expected by the source code
345
- expect(() => { throw new Error('Request blocked by interceptor'); }).toThrow('blocked by interceptor');
346
- });
347
- });
348
-
349
- describe('http.onResponse - extra', () => {
350
- it('response interceptors are additive', async () => {
351
- // onResponse was already called in earlier tests; verify the callback
352
- const spy = vi.fn();
353
- http.onResponse(spy);
354
- mockFetch({ ok: true });
355
- await http.get('https://api.test.com/data123');
356
- expect(spy).toHaveBeenCalledTimes(1);
357
- });
358
- });
359
-
360
-
361
- // ===========================================================================
362
- // DELETE with body
363
- // ===========================================================================
364
-
365
- describe('http.delete', () => {
366
- it('sends a DELETE request', async () => {
367
- mockFetch({ deleted: true });
368
- const result = await http.delete('https://api.test.com/item/1');
369
- expect(fetchSpy.mock.calls[0][1].method).toBe('DELETE');
370
- expect(result.data).toEqual({ deleted: true });
371
- });
372
-
373
- it('DELETE with body', async () => {
374
- mockFetch({ deleted: true });
375
- await http.delete('https://api.test.com/items', { ids: [1, 2] });
376
- const opts = fetchSpy.mock.calls[0][1];
377
- expect(opts.method).toBe('DELETE');
378
- expect(JSON.parse(opts.body)).toEqual({ ids: [1, 2] });
379
- });
380
- });
381
-
382
-
383
- // ===========================================================================
384
- // GET with existing query string
385
- // ===========================================================================
386
-
387
- describe('http.get - query params', () => {
388
- it('appends params with & when URL already has ?', async () => {
389
- mockFetch({ ok: true });
390
- await http.get('https://api.test.com/search?q=hello', { page: 2 });
391
- expect(fetchSpy.mock.calls[0][0]).toContain('?q=hello&page=2');
392
- });
393
- });
394
-
395
-
396
- // ===========================================================================
397
- // string body
398
- // ===========================================================================
399
-
400
- describe('http.post - string body', () => {
401
- it('sends string body as-is', async () => {
402
- mockFetch({ ok: true });
403
- await http.post('https://api.test.com/raw', 'plain text body');
404
- const opts = fetchSpy.mock.calls[0][1];
405
- expect(opts.body).toBe('plain text body');
406
- });
407
- });
408
-
409
-
410
- // ===========================================================================
411
- // URL validation
412
- // ===========================================================================
413
-
414
- describe('http - URL validation', () => {
415
- it('throws on missing URL', async () => {
416
- await expect(http.get(undefined)).rejects.toThrow('URL string');
417
- });
418
-
419
- it('throws on non-string URL', async () => {
420
- await expect(http.get(123)).rejects.toThrow('URL string');
421
- });
422
- });
423
-
424
-
425
- // ===========================================================================
426
- // createAbort
427
- // ===========================================================================
428
-
429
- describe('http.createAbort', () => {
430
- it('returns an AbortController', () => {
431
- const controller = http.createAbort();
432
- expect(controller).toBeInstanceOf(AbortController);
433
- });
434
- });
435
-
436
-
437
- // ===========================================================================
438
- // timeout / abort
439
- // ===========================================================================
440
-
441
- describe('http - AbortController integration', () => {
442
- it('createAbort signal can be passed as option', async () => {
443
- const controller = http.createAbort();
444
- mockFetch({ ok: true });
445
- await http.get('https://api.test.com/data', undefined, { signal: controller.signal });
446
- expect(fetchSpy).toHaveBeenCalled();
447
- });
448
- });
449
-
450
-
451
- // ===========================================================================
452
- // HEAD requests
453
- // ===========================================================================
454
-
455
- describe('http.head', () => {
456
- it('sends a HEAD request', async () => {
457
- mockFetch({});
458
- const result = await http.head('https://api.test.com/resource');
459
- expect(fetchSpy.mock.calls[0][1].method).toBe('HEAD');
460
- expect(result.ok).toBe(true);
461
- expect(result.status).toBe(200);
462
- });
463
-
464
- it('does not send a body', async () => {
465
- mockFetch({});
466
- await http.head('https://api.test.com/resource');
467
- const opts = fetchSpy.mock.calls[0][1];
468
- expect(opts.body).toBeUndefined();
469
- });
470
-
471
- it('accepts per-request options', async () => {
472
- mockFetch({});
473
- await http.head('https://api.test.com/resource', {
474
- headers: { 'X-Check': 'exists' },
475
- });
476
- const opts = fetchSpy.mock.calls[0][1];
477
- expect(opts.headers['X-Check']).toBe('exists');
478
- });
479
-
480
- it('returns response headers', async () => {
481
- mockFetch({});
482
- const result = await http.head('https://api.test.com/resource');
483
- expect(result.headers).toBeDefined();
484
- expect(typeof result.headers).toBe('object');
485
- });
486
- });
487
-
488
-
489
- // ===========================================================================
490
- // Interceptor unsubscribe
491
- // ===========================================================================
492
-
493
- describe('http - interceptor unsubscribe', () => {
494
- it('onRequest returns an unsubscribe function', async () => {
495
- http.clearInterceptors();
496
- const spy = vi.fn();
497
- const unsub = http.onRequest(spy);
498
- expect(typeof unsub).toBe('function');
499
-
500
- mockFetch({});
501
- await http.get('https://api.test.com/a');
502
- expect(spy).toHaveBeenCalledTimes(1);
503
-
504
- unsub();
505
- await http.get('https://api.test.com/b');
506
- expect(spy).toHaveBeenCalledTimes(1); // not called again
507
- });
508
-
509
- it('onResponse returns an unsubscribe function', async () => {
510
- http.clearInterceptors();
511
- const spy = vi.fn();
512
- const unsub = http.onResponse(spy);
513
- expect(typeof unsub).toBe('function');
514
-
515
- mockFetch({});
516
- await http.get('https://api.test.com/a');
517
- expect(spy).toHaveBeenCalledTimes(1);
518
-
519
- unsub();
520
- await http.get('https://api.test.com/b');
521
- expect(spy).toHaveBeenCalledTimes(1);
522
- });
523
-
524
- it('double-unsubscribe is safe', async () => {
525
- http.clearInterceptors();
526
- const spy = vi.fn();
527
- const unsub = http.onRequest(spy);
528
- unsub();
529
- unsub(); // should not throw
530
- mockFetch({});
531
- await http.get('https://api.test.com/data');
532
- expect(spy).not.toHaveBeenCalled();
533
- });
534
- });
535
-
536
-
537
- // ===========================================================================
538
- // clearInterceptors
539
- // ===========================================================================
540
-
541
- describe('http.clearInterceptors', () => {
542
- it('clears all interceptors when called with no args', async () => {
543
- http.clearInterceptors();
544
- const reqSpy = vi.fn();
545
- const resSpy = vi.fn();
546
- http.onRequest(reqSpy);
547
- http.onResponse(resSpy);
548
-
549
- http.clearInterceptors();
550
- mockFetch({});
551
- await http.get('https://api.test.com/data');
552
- expect(reqSpy).not.toHaveBeenCalled();
553
- expect(resSpy).not.toHaveBeenCalled();
554
- });
555
-
556
- it('clears only request interceptors with "request"', async () => {
557
- http.clearInterceptors();
558
- const reqSpy = vi.fn();
559
- const resSpy = vi.fn();
560
- http.onRequest(reqSpy);
561
- http.onResponse(resSpy);
562
-
563
- http.clearInterceptors('request');
564
- mockFetch({});
565
- await http.get('https://api.test.com/data');
566
- expect(reqSpy).not.toHaveBeenCalled();
567
- expect(resSpy).toHaveBeenCalledTimes(1);
568
- });
569
-
570
- it('clears only response interceptors with "response"', async () => {
571
- http.clearInterceptors();
572
- const reqSpy = vi.fn();
573
- const resSpy = vi.fn();
574
- http.onRequest(reqSpy);
575
- http.onResponse(resSpy);
576
-
577
- http.clearInterceptors('response');
578
- mockFetch({});
579
- await http.get('https://api.test.com/data');
580
- expect(reqSpy).toHaveBeenCalledTimes(1);
581
- expect(resSpy).not.toHaveBeenCalled();
582
- });
583
- });
584
-
585
-
586
- // ===========================================================================
587
- // http.all - parallel requests
588
- // ===========================================================================
589
-
590
- describe('http.all', () => {
591
- it('resolves all parallel requests', async () => {
592
- mockFetch({ ok: true });
593
- const results = await http.all([
594
- http.get('https://api.test.com/a'),
595
- http.get('https://api.test.com/b'),
596
- http.get('https://api.test.com/c'),
597
- ]);
598
- expect(results).toHaveLength(3);
599
- expect(results.every(r => r.ok)).toBe(true);
600
- });
601
-
602
- it('rejects if any request fails', async () => {
603
- http.clearInterceptors();
604
- mockFetch({ error: 'fail' }, false, 500);
605
- await expect(
606
- http.all([
607
- http.get('https://api.test.com/a'),
608
- http.get('https://api.test.com/b'),
609
- ])
610
- ).rejects.toThrow();
611
- });
612
-
613
- it('handles empty array', async () => {
614
- const results = await http.all([]);
615
- expect(results).toEqual([]);
616
- });
617
- });
618
-
619
-
620
- // ===========================================================================
621
- // http.getConfig
622
- // ===========================================================================
623
-
624
- describe('http.getConfig', () => {
625
- it('returns current config', () => {
626
- http.configure({ baseURL: 'https://myapi.com', timeout: 5000 });
627
- const config = http.getConfig();
628
- expect(config.baseURL).toBe('https://myapi.com');
629
- expect(config.timeout).toBe(5000);
630
- expect(config.headers).toBeDefined();
631
- });
632
-
633
- it('returns a copy (not the internal reference)', () => {
634
- const config = http.getConfig();
635
- config.baseURL = 'https://mutated.com';
636
- config.headers['X-Evil'] = 'injected';
637
- const fresh = http.getConfig();
638
- expect(fresh.baseURL).not.toBe('https://mutated.com');
639
- expect(fresh.headers['X-Evil']).toBeUndefined();
640
- });
641
-
642
- it('reflects updates after configure', () => {
643
- http.configure({ baseURL: '' });
644
- expect(http.getConfig().baseURL).toBe('');
645
- http.configure({ baseURL: 'https://updated.com' });
646
- expect(http.getConfig().baseURL).toBe('https://updated.com');
647
- });
648
- });
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { http } from '../src/http.js';
3
+
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Mock fetch
7
+ // ---------------------------------------------------------------------------
8
+ let fetchSpy;
9
+
10
+ function mockFetch(response = {}, ok = true, status = 200) {
11
+ const body = typeof response === 'string' ? response : JSON.stringify(response);
12
+ const contentType = typeof response === 'string' ? 'text/plain' : 'application/json';
13
+
14
+ fetchSpy = vi.fn(() =>
15
+ Promise.resolve({
16
+ ok,
17
+ status,
18
+ statusText: ok ? 'OK' : 'Error',
19
+ headers: {
20
+ get: (h) => h.toLowerCase() === 'content-type' ? contentType : null,
21
+ entries: () => [[`content-type`, contentType]],
22
+ },
23
+ json: () => Promise.resolve(typeof response === 'object' ? response : JSON.parse(response)),
24
+ text: () => Promise.resolve(body),
25
+ blob: () => Promise.resolve(new Blob([body])),
26
+ })
27
+ );
28
+ globalThis.fetch = fetchSpy;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ http.configure({ baseURL: '', headers: { 'Content-Type': 'application/json' }, timeout: 30000 });
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // GET requests
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe('http.get', () => {
45
+ it('makes a GET request', async () => {
46
+ mockFetch({ users: [] });
47
+ const result = await http.get('https://api.test.com/users');
48
+ expect(fetchSpy).toHaveBeenCalledOnce();
49
+ expect(result.data).toEqual({ users: [] });
50
+ expect(result.ok).toBe(true);
51
+ expect(result.status).toBe(200);
52
+ });
53
+
54
+ it('appends query params for GET', async () => {
55
+ mockFetch({});
56
+ await http.get('https://api.test.com/search', { q: 'hello', page: '1' });
57
+ const url = fetchSpy.mock.calls[0][0];
58
+ expect(url).toContain('q=hello');
59
+ expect(url).toContain('page=1');
60
+ });
61
+
62
+ it('uses baseURL', async () => {
63
+ http.configure({ baseURL: 'https://api.test.com' });
64
+ mockFetch({});
65
+ await http.get('/users');
66
+ expect(fetchSpy.mock.calls[0][0]).toBe('https://api.test.com/users');
67
+ });
68
+ });
69
+
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // POST requests
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('http.post', () => {
76
+ it('sends JSON body', async () => {
77
+ mockFetch({ id: 1 });
78
+ const result = await http.post('https://api.test.com/users', { name: 'Tony' });
79
+ const opts = fetchSpy.mock.calls[0][1];
80
+ expect(opts.method).toBe('POST');
81
+ expect(opts.body).toBe(JSON.stringify({ name: 'Tony' }));
82
+ expect(result.data).toEqual({ id: 1 });
83
+ });
84
+
85
+ it('handles FormData body', async () => {
86
+ mockFetch({});
87
+ const form = new FormData();
88
+ form.append('file', 'data');
89
+ await http.post('https://api.test.com/upload', form);
90
+ const opts = fetchSpy.mock.calls[0][1];
91
+ expect(opts.body).toBe(form);
92
+ // Content-Type should be removed for FormData
93
+ expect(opts.headers['Content-Type']).toBeUndefined();
94
+ });
95
+ });
96
+
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Error handling
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe('http - error handling', () => {
103
+ it('throws on non-ok response', async () => {
104
+ mockFetch({ error: 'Not Found' }, false, 404);
105
+ await expect(http.get('https://api.test.com/missing')).rejects.toThrow('HTTP 404');
106
+ });
107
+
108
+ it('error includes response data', async () => {
109
+ mockFetch({ message: 'Unauthorized' }, false, 401);
110
+ try {
111
+ await http.get('https://api.test.com/protected');
112
+ } catch (err) {
113
+ expect(err.response).toBeDefined();
114
+ expect(err.response.status).toBe(401);
115
+ }
116
+ });
117
+
118
+ it('throws on invalid URL', async () => {
119
+ await expect(http.get(null)).rejects.toThrow('requires a URL string');
120
+ await expect(http.get(undefined)).rejects.toThrow('requires a URL string');
121
+ });
122
+ });
123
+
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // PUT / PATCH / DELETE
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe('http - other methods', () => {
130
+ it('PUT sends correct method', async () => {
131
+ mockFetch({});
132
+ await http.put('https://api.test.com/users/1', { name: 'Updated' });
133
+ expect(fetchSpy.mock.calls[0][1].method).toBe('PUT');
134
+ });
135
+
136
+ it('PATCH sends correct method', async () => {
137
+ mockFetch({});
138
+ await http.patch('https://api.test.com/users/1', { name: 'Patched' });
139
+ expect(fetchSpy.mock.calls[0][1].method).toBe('PATCH');
140
+ });
141
+
142
+ it('DELETE sends correct method', async () => {
143
+ mockFetch({});
144
+ await http.delete('https://api.test.com/users/1');
145
+ expect(fetchSpy.mock.calls[0][1].method).toBe('DELETE');
146
+ });
147
+ });
148
+
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // configure
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('http.configure', () => {
155
+ it('sets baseURL', async () => {
156
+ http.configure({ baseURL: 'https://example.com/api' });
157
+ mockFetch({});
158
+ await http.get('/data');
159
+ expect(fetchSpy.mock.calls[0][0]).toBe('https://example.com/api/data');
160
+ });
161
+
162
+ it('merges headers', async () => {
163
+ http.configure({ headers: { Authorization: 'Bearer token' } });
164
+ mockFetch({});
165
+ await http.get('https://api.test.com/me');
166
+ expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer token');
167
+ });
168
+ });
169
+
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Text response
173
+ // ---------------------------------------------------------------------------
174
+
175
+ describe('http - text response', () => {
176
+ it('parses text response', async () => {
177
+ mockFetch('Hello World');
178
+ const result = await http.get('https://api.test.com/text');
179
+ expect(result.data).toBe('Hello World');
180
+ });
181
+ });
182
+
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Interceptors
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe('http - interceptors', () => {
189
+ it('request interceptor via onRequest', async () => {
190
+ http.configure({ baseURL: '' });
191
+ http.onRequest((fetchOpts, url) => {
192
+ fetchOpts.headers['X-Custom'] = 'test';
193
+ });
194
+ mockFetch({});
195
+ await http.get('https://api.test.com/data');
196
+ const opts = fetchSpy.mock.calls[0][1];
197
+ expect(opts.headers['X-Custom']).toBe('test');
198
+ });
199
+
200
+ it('response interceptor via onResponse', async () => {
201
+ http.onResponse((result) => {
202
+ result.intercepted = true;
203
+ });
204
+ mockFetch({ x: 1 });
205
+ const result = await http.get('https://api.test.com/data');
206
+ expect(result.intercepted).toBe(true);
207
+ });
208
+ });
209
+
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Timeout / abort
213
+ // ---------------------------------------------------------------------------
214
+
215
+ describe('http - abort signal', () => {
216
+ it('passes signal through options', async () => {
217
+ const controller = new AbortController();
218
+ mockFetch({});
219
+ await http.get('https://api.test.com/data', null, { signal: controller.signal });
220
+ const opts = fetchSpy.mock.calls[0][1];
221
+ // Signal may be combined via AbortSignal.any - verify it responds to user abort
222
+ expect(opts.signal).toBeDefined();
223
+ expect(opts.signal.aborted).toBe(false);
224
+ controller.abort();
225
+ expect(opts.signal.aborted).toBe(true);
226
+ });
227
+ });
228
+
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Blob response
232
+ // ---------------------------------------------------------------------------
233
+
234
+ describe('http - blob response', () => {
235
+ it('can request blob responses', async () => {
236
+ mockFetch('binary data');
237
+ const result = await http.get('https://api.test.com/file', null, { responseType: 'blob' });
238
+ // Should have a data field (blob or fallback text)
239
+ expect(result.data).toBeDefined();
240
+ });
241
+ });
242
+
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // HEAD and OPTIONS methods
246
+ // ---------------------------------------------------------------------------
247
+
248
+ describe('http - raw fetch pass-through', () => {
249
+ it('raw() delegates to native fetch', async () => {
250
+ mockFetch({ ok: true });
251
+ await http.raw('https://api.test.com/ping', { method: 'HEAD' });
252
+ expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com/ping', { method: 'HEAD' });
253
+ });
254
+ });
255
+
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Request with custom headers
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe('http - custom per-request headers', () => {
262
+ it('merges per-request headers', async () => {
263
+ mockFetch({});
264
+ await http.get('https://api.test.com/data', null, {
265
+ headers: { 'X-Request-Id': '123' },
266
+ });
267
+ const opts = fetchSpy.mock.calls[0][1];
268
+ expect(opts.headers['X-Request-Id']).toBe('123');
269
+ });
270
+ });
271
+
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Response metadata
275
+ // ---------------------------------------------------------------------------
276
+
277
+ describe('http - response metadata', () => {
278
+ it('includes status and ok in result', async () => {
279
+ mockFetch({ data: 'yes' }, true, 200);
280
+ const result = await http.get('https://api.test.com/data');
281
+ expect(result.ok).toBe(true);
282
+ expect(result.status).toBe(200);
283
+ });
284
+
285
+ it('includes statusText in error', async () => {
286
+ mockFetch({ error: 'bad' }, false, 500);
287
+ try {
288
+ await http.get('https://api.test.com/fail');
289
+ } catch (err) {
290
+ expect(err.message).toContain('500');
291
+ }
292
+ });
293
+ });
294
+
295
+
296
+ // ===========================================================================
297
+ // configure()
298
+ // ===========================================================================
299
+
300
+ describe('http.configure', () => {
301
+ it('sets baseURL', async () => {
302
+ http.configure({ baseURL: 'https://api.myapp.com' });
303
+ mockFetch({ ok: true });
304
+ await http.get('/users');
305
+ expect(fetchSpy.mock.calls[0][0]).toBe('https://api.myapp.com/users');
306
+ });
307
+
308
+ it('merges headers', async () => {
309
+ http.configure({ headers: { Authorization: 'Bearer token123' } });
310
+ mockFetch({ ok: true });
311
+ await http.get('https://api.test.com/me');
312
+ const opts = fetchSpy.mock.calls[0][1];
313
+ expect(opts.headers['Authorization']).toBe('Bearer token123');
314
+ expect(opts.headers['Content-Type']).toBe('application/json');
315
+ });
316
+
317
+ it('sets timeout', () => {
318
+ http.configure({ timeout: 5000 });
319
+ // Just verifying it doesn't throw
320
+ expect(true).toBe(true);
321
+ });
322
+ });
323
+
324
+
325
+ // ===========================================================================
326
+ // interceptors
327
+ // ===========================================================================
328
+
329
+ describe('http.onRequest - extra', () => {
330
+ it('interceptor modifying URL', async () => {
331
+ // Note: interceptors from earlier tests may still be registered; this one
332
+ // only adds a query param so it doesn't break others
333
+ http.onRequest((opts, url) => ({ url: url + '?token=abc', options: opts }));
334
+ mockFetch({ ok: true });
335
+ await http.get('https://api.test.com/data');
336
+ expect(fetchSpy.mock.calls[0][0]).toContain('?token=abc');
337
+ });
338
+ });
339
+
340
+ // Test interceptor blocking in isolation - it permanently modifies internal state,
341
+ // so we verify behavior without adding a blocking interceptor that breaks later tests
342
+ describe('http - interceptor blocking concept', () => {
343
+ it('onRequest returning false would throw blocked error', () => {
344
+ // Just verify the error message format expected by the source code
345
+ expect(() => { throw new Error('Request blocked by interceptor'); }).toThrow('blocked by interceptor');
346
+ });
347
+ });
348
+
349
+ describe('http.onResponse - extra', () => {
350
+ it('response interceptors are additive', async () => {
351
+ // onResponse was already called in earlier tests; verify the callback
352
+ const spy = vi.fn();
353
+ http.onResponse(spy);
354
+ mockFetch({ ok: true });
355
+ await http.get('https://api.test.com/data123');
356
+ expect(spy).toHaveBeenCalledTimes(1);
357
+ });
358
+ });
359
+
360
+
361
+ // ===========================================================================
362
+ // DELETE with body
363
+ // ===========================================================================
364
+
365
+ describe('http.delete', () => {
366
+ it('sends a DELETE request', async () => {
367
+ mockFetch({ deleted: true });
368
+ const result = await http.delete('https://api.test.com/item/1');
369
+ expect(fetchSpy.mock.calls[0][1].method).toBe('DELETE');
370
+ expect(result.data).toEqual({ deleted: true });
371
+ });
372
+
373
+ it('DELETE with body', async () => {
374
+ mockFetch({ deleted: true });
375
+ await http.delete('https://api.test.com/items', { ids: [1, 2] });
376
+ const opts = fetchSpy.mock.calls[0][1];
377
+ expect(opts.method).toBe('DELETE');
378
+ expect(JSON.parse(opts.body)).toEqual({ ids: [1, 2] });
379
+ });
380
+ });
381
+
382
+
383
+ // ===========================================================================
384
+ // GET with existing query string
385
+ // ===========================================================================
386
+
387
+ describe('http.get - query params', () => {
388
+ it('appends params with & when URL already has ?', async () => {
389
+ mockFetch({ ok: true });
390
+ await http.get('https://api.test.com/search?q=hello', { page: 2 });
391
+ expect(fetchSpy.mock.calls[0][0]).toContain('?q=hello&page=2');
392
+ });
393
+ });
394
+
395
+
396
+ // ===========================================================================
397
+ // string body
398
+ // ===========================================================================
399
+
400
+ describe('http.post - string body', () => {
401
+ it('sends string body as-is', async () => {
402
+ mockFetch({ ok: true });
403
+ await http.post('https://api.test.com/raw', 'plain text body');
404
+ const opts = fetchSpy.mock.calls[0][1];
405
+ expect(opts.body).toBe('plain text body');
406
+ });
407
+ });
408
+
409
+
410
+ // ===========================================================================
411
+ // URL validation
412
+ // ===========================================================================
413
+
414
+ describe('http - URL validation', () => {
415
+ it('throws on missing URL', async () => {
416
+ await expect(http.get(undefined)).rejects.toThrow('URL string');
417
+ });
418
+
419
+ it('throws on non-string URL', async () => {
420
+ await expect(http.get(123)).rejects.toThrow('URL string');
421
+ });
422
+ });
423
+
424
+
425
+ // ===========================================================================
426
+ // createAbort
427
+ // ===========================================================================
428
+
429
+ describe('http.createAbort', () => {
430
+ it('returns an AbortController', () => {
431
+ const controller = http.createAbort();
432
+ expect(controller).toBeInstanceOf(AbortController);
433
+ });
434
+ });
435
+
436
+
437
+ // ===========================================================================
438
+ // timeout / abort
439
+ // ===========================================================================
440
+
441
+ describe('http - AbortController integration', () => {
442
+ it('createAbort signal can be passed as option', async () => {
443
+ const controller = http.createAbort();
444
+ mockFetch({ ok: true });
445
+ await http.get('https://api.test.com/data', undefined, { signal: controller.signal });
446
+ expect(fetchSpy).toHaveBeenCalled();
447
+ });
448
+ });
449
+
450
+
451
+ // ===========================================================================
452
+ // HEAD requests
453
+ // ===========================================================================
454
+
455
+ describe('http.head', () => {
456
+ it('sends a HEAD request', async () => {
457
+ mockFetch({});
458
+ const result = await http.head('https://api.test.com/resource');
459
+ expect(fetchSpy.mock.calls[0][1].method).toBe('HEAD');
460
+ expect(result.ok).toBe(true);
461
+ expect(result.status).toBe(200);
462
+ });
463
+
464
+ it('does not send a body', async () => {
465
+ mockFetch({});
466
+ await http.head('https://api.test.com/resource');
467
+ const opts = fetchSpy.mock.calls[0][1];
468
+ expect(opts.body).toBeUndefined();
469
+ });
470
+
471
+ it('accepts per-request options', async () => {
472
+ mockFetch({});
473
+ await http.head('https://api.test.com/resource', {
474
+ headers: { 'X-Check': 'exists' },
475
+ });
476
+ const opts = fetchSpy.mock.calls[0][1];
477
+ expect(opts.headers['X-Check']).toBe('exists');
478
+ });
479
+
480
+ it('returns response headers', async () => {
481
+ mockFetch({});
482
+ const result = await http.head('https://api.test.com/resource');
483
+ expect(result.headers).toBeDefined();
484
+ expect(typeof result.headers).toBe('object');
485
+ });
486
+ });
487
+
488
+
489
+ // ===========================================================================
490
+ // Interceptor unsubscribe
491
+ // ===========================================================================
492
+
493
+ describe('http - interceptor unsubscribe', () => {
494
+ it('onRequest returns an unsubscribe function', async () => {
495
+ http.clearInterceptors();
496
+ const spy = vi.fn();
497
+ const unsub = http.onRequest(spy);
498
+ expect(typeof unsub).toBe('function');
499
+
500
+ mockFetch({});
501
+ await http.get('https://api.test.com/a');
502
+ expect(spy).toHaveBeenCalledTimes(1);
503
+
504
+ unsub();
505
+ await http.get('https://api.test.com/b');
506
+ expect(spy).toHaveBeenCalledTimes(1); // not called again
507
+ });
508
+
509
+ it('onResponse returns an unsubscribe function', async () => {
510
+ http.clearInterceptors();
511
+ const spy = vi.fn();
512
+ const unsub = http.onResponse(spy);
513
+ expect(typeof unsub).toBe('function');
514
+
515
+ mockFetch({});
516
+ await http.get('https://api.test.com/a');
517
+ expect(spy).toHaveBeenCalledTimes(1);
518
+
519
+ unsub();
520
+ await http.get('https://api.test.com/b');
521
+ expect(spy).toHaveBeenCalledTimes(1);
522
+ });
523
+
524
+ it('double-unsubscribe is safe', async () => {
525
+ http.clearInterceptors();
526
+ const spy = vi.fn();
527
+ const unsub = http.onRequest(spy);
528
+ unsub();
529
+ unsub(); // should not throw
530
+ mockFetch({});
531
+ await http.get('https://api.test.com/data');
532
+ expect(spy).not.toHaveBeenCalled();
533
+ });
534
+ });
535
+
536
+
537
+ // ===========================================================================
538
+ // clearInterceptors
539
+ // ===========================================================================
540
+
541
+ describe('http.clearInterceptors', () => {
542
+ it('clears all interceptors when called with no args', async () => {
543
+ http.clearInterceptors();
544
+ const reqSpy = vi.fn();
545
+ const resSpy = vi.fn();
546
+ http.onRequest(reqSpy);
547
+ http.onResponse(resSpy);
548
+
549
+ http.clearInterceptors();
550
+ mockFetch({});
551
+ await http.get('https://api.test.com/data');
552
+ expect(reqSpy).not.toHaveBeenCalled();
553
+ expect(resSpy).not.toHaveBeenCalled();
554
+ });
555
+
556
+ it('clears only request interceptors with "request"', async () => {
557
+ http.clearInterceptors();
558
+ const reqSpy = vi.fn();
559
+ const resSpy = vi.fn();
560
+ http.onRequest(reqSpy);
561
+ http.onResponse(resSpy);
562
+
563
+ http.clearInterceptors('request');
564
+ mockFetch({});
565
+ await http.get('https://api.test.com/data');
566
+ expect(reqSpy).not.toHaveBeenCalled();
567
+ expect(resSpy).toHaveBeenCalledTimes(1);
568
+ });
569
+
570
+ it('clears only response interceptors with "response"', async () => {
571
+ http.clearInterceptors();
572
+ const reqSpy = vi.fn();
573
+ const resSpy = vi.fn();
574
+ http.onRequest(reqSpy);
575
+ http.onResponse(resSpy);
576
+
577
+ http.clearInterceptors('response');
578
+ mockFetch({});
579
+ await http.get('https://api.test.com/data');
580
+ expect(reqSpy).toHaveBeenCalledTimes(1);
581
+ expect(resSpy).not.toHaveBeenCalled();
582
+ });
583
+ });
584
+
585
+
586
+ // ===========================================================================
587
+ // http.all - parallel requests
588
+ // ===========================================================================
589
+
590
+ describe('http.all', () => {
591
+ it('resolves all parallel requests', async () => {
592
+ mockFetch({ ok: true });
593
+ const results = await http.all([
594
+ http.get('https://api.test.com/a'),
595
+ http.get('https://api.test.com/b'),
596
+ http.get('https://api.test.com/c'),
597
+ ]);
598
+ expect(results).toHaveLength(3);
599
+ expect(results.every(r => r.ok)).toBe(true);
600
+ });
601
+
602
+ it('rejects if any request fails', async () => {
603
+ http.clearInterceptors();
604
+ mockFetch({ error: 'fail' }, false, 500);
605
+ await expect(
606
+ http.all([
607
+ http.get('https://api.test.com/a'),
608
+ http.get('https://api.test.com/b'),
609
+ ])
610
+ ).rejects.toThrow();
611
+ });
612
+
613
+ it('handles empty array', async () => {
614
+ const results = await http.all([]);
615
+ expect(results).toEqual([]);
616
+ });
617
+ });
618
+
619
+
620
+ // ===========================================================================
621
+ // http.getConfig
622
+ // ===========================================================================
623
+
624
+ describe('http.getConfig', () => {
625
+ it('returns current config', () => {
626
+ http.configure({ baseURL: 'https://myapi.com', timeout: 5000 });
627
+ const config = http.getConfig();
628
+ expect(config.baseURL).toBe('https://myapi.com');
629
+ expect(config.timeout).toBe(5000);
630
+ expect(config.headers).toBeDefined();
631
+ });
632
+
633
+ it('returns a copy (not the internal reference)', () => {
634
+ const config = http.getConfig();
635
+ config.baseURL = 'https://mutated.com';
636
+ config.headers['X-Evil'] = 'injected';
637
+ const fresh = http.getConfig();
638
+ expect(fresh.baseURL).not.toBe('https://mutated.com');
639
+ expect(fresh.headers['X-Evil']).toBeUndefined();
640
+ });
641
+
642
+ it('reflects updates after configure', () => {
643
+ http.configure({ baseURL: '' });
644
+ expect(http.getConfig().baseURL).toBe('');
645
+ http.configure({ baseURL: 'https://updated.com' });
646
+ expect(http.getConfig().baseURL).toBe('https://updated.com');
647
+ });
648
+ });