@signalium/query 0.0.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.
@@ -0,0 +1,564 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { SyncQueryStore, MemoryPersistentStore } from '../QueryStore.js';
3
+ import { QueryClient } from '../QueryClient.js';
4
+ import { entity, t } from '../typeDefs.js';
5
+ import { query } from '../query.js';
6
+ import { createMockFetch, testWithClient, getEntityMapSize } from './utils.js';
7
+
8
+ /**
9
+ * REST Query API Tests
10
+ *
11
+ * These tests focus on the PUBLIC query() API - what users will actually use.
12
+ * All external fetch calls are mocked.
13
+ */
14
+
15
+ describe('REST Query API', () => {
16
+ let client: QueryClient;
17
+ let mockFetch: ReturnType<typeof createMockFetch>;
18
+
19
+ beforeEach(() => {
20
+ const store = new SyncQueryStore(new MemoryPersistentStore());
21
+ mockFetch = createMockFetch();
22
+ client = new QueryClient(store, { fetch: mockFetch as any });
23
+ });
24
+
25
+ describe('Basic Query Execution', () => {
26
+ it('should execute a GET query with path parameters', async () => {
27
+ mockFetch.get('/users/[id]', { id: 123, name: 'Test User' });
28
+
29
+ const getUser = query(t => ({
30
+ path: '/users/[id]',
31
+ response: {
32
+ id: t.number,
33
+ name: t.string,
34
+ },
35
+ }));
36
+
37
+ await testWithClient(client, async () => {
38
+ const relay = getUser({ id: '123' });
39
+ const result = await relay;
40
+
41
+ expect(result.id).toBe(123);
42
+ expect(result.name).toBe('Test User');
43
+ expect(mockFetch.calls[0].url).toBe('/users/123');
44
+ expect(mockFetch.calls[0].options.method).toBe('GET');
45
+ });
46
+ });
47
+
48
+ it('should execute a GET query with search parameters', async () => {
49
+ mockFetch.get('/users', { users: [], page: 1, total: 0 });
50
+
51
+ const listUsers = query(t => ({
52
+ path: '/users',
53
+ searchParams: {
54
+ page: t.number,
55
+ limit: t.number,
56
+ },
57
+ response: {
58
+ users: t.array(
59
+ t.object({
60
+ id: t.number,
61
+ name: t.string,
62
+ }),
63
+ ),
64
+ page: t.number,
65
+ total: t.number,
66
+ },
67
+ }));
68
+
69
+ await testWithClient(client, async () => {
70
+ const relay = listUsers({ page: 1, limit: 10 });
71
+ const result = await relay;
72
+
73
+ expect(result.page).toBe(1);
74
+ expect(result.total).toBe(0);
75
+ // Verify URL was constructed with search params
76
+ const callUrl = mockFetch.calls[0].url;
77
+ expect(callUrl).toContain('page=1');
78
+ expect(callUrl).toContain('limit=10');
79
+ });
80
+ });
81
+
82
+ it('should execute a GET query with both path and search params', async () => {
83
+ mockFetch.get('/users/[userId]/posts', { posts: [], userId: 5 });
84
+
85
+ const getUserPosts = query(t => ({
86
+ path: '/users/[userId]/posts',
87
+ searchParams: {
88
+ status: t.string,
89
+ },
90
+ response: {
91
+ posts: t.array(
92
+ t.object({
93
+ id: t.number,
94
+ title: t.string,
95
+ }),
96
+ ),
97
+ userId: t.number,
98
+ },
99
+ }));
100
+
101
+ await testWithClient(client, async () => {
102
+ const relay = getUserPosts({ userId: '5', status: 'published' });
103
+ const result = await relay;
104
+
105
+ expect(result.userId).toBe(5);
106
+ const callUrl = mockFetch.calls[0].url;
107
+ expect(callUrl).toContain('/users/5/posts');
108
+ expect(callUrl).toContain('status=published');
109
+ });
110
+ });
111
+
112
+ it('should execute POST requests', async () => {
113
+ mockFetch.post('/users', { id: 456, name: 'New User', created: true });
114
+
115
+ const createUser = query(t => ({
116
+ path: '/users',
117
+ method: 'POST',
118
+ response: {
119
+ id: t.number,
120
+ name: t.string,
121
+ created: t.boolean,
122
+ },
123
+ }));
124
+
125
+ await testWithClient(client, async () => {
126
+ const relay = createUser();
127
+ const result = await relay;
128
+
129
+ expect(result.id).toBe(456);
130
+ expect(result.created).toBe(true);
131
+ expect(mockFetch.calls[0].url).toBe('/users');
132
+ expect(mockFetch.calls[0].options.method).toBe('POST');
133
+ });
134
+ });
135
+
136
+ it('should execute PUT requests', async () => {
137
+ mockFetch.put('/users/[id]', { id: 123, name: 'Updated User', updated: true });
138
+
139
+ const updateUser = query(t => ({
140
+ path: '/users/[id]',
141
+ method: 'PUT',
142
+ response: {
143
+ id: t.number,
144
+ name: t.string,
145
+ updated: t.boolean,
146
+ },
147
+ }));
148
+
149
+ await testWithClient(client, async () => {
150
+ const relay = updateUser({ id: '123' });
151
+ const result = await relay;
152
+
153
+ expect(result.updated).toBe(true);
154
+ expect(mockFetch.calls[0].url).toBe('/users/123');
155
+ expect(mockFetch.calls[0].options.method).toBe('PUT');
156
+ });
157
+ });
158
+
159
+ it('should execute DELETE requests', async () => {
160
+ mockFetch.delete('/users/[id]', { success: true, id: 123 });
161
+
162
+ const deleteUser = query(t => ({
163
+ path: '/users/[id]',
164
+ method: 'DELETE',
165
+ response: {
166
+ success: t.boolean,
167
+ id: t.number,
168
+ },
169
+ }));
170
+
171
+ await testWithClient(client, async () => {
172
+ const relay = deleteUser({ id: '123' });
173
+ const result = await relay;
174
+
175
+ expect(result.success).toBe(true);
176
+ expect(mockFetch.calls[0].url).toBe('/users/123');
177
+ expect(mockFetch.calls[0].options.method).toBe('DELETE');
178
+ });
179
+ });
180
+
181
+ it('should execute PATCH requests', async () => {
182
+ mockFetch.patch('/users/[id]', {
183
+ id: 123,
184
+ email: 'new@example.com',
185
+ patched: true,
186
+ });
187
+
188
+ const patchUser = query(t => ({
189
+ path: '/users/[id]',
190
+ method: 'PATCH',
191
+ response: {
192
+ id: t.number,
193
+ email: t.string,
194
+ patched: t.boolean,
195
+ },
196
+ }));
197
+
198
+ await testWithClient(client, async () => {
199
+ const relay = patchUser({ id: '123' });
200
+ const result = await relay;
201
+
202
+ expect(result.patched).toBe(true);
203
+ expect(mockFetch.calls[0].url).toBe('/users/123');
204
+ expect(mockFetch.calls[0].options.method).toBe('PATCH');
205
+ });
206
+ });
207
+ });
208
+
209
+ describe('Error Handling', () => {
210
+ it('should handle network errors', async () => {
211
+ const error = new Error('Network connection failed');
212
+ mockFetch.get('/users/[id]', null, { error });
213
+
214
+ const getUser = query(t => ({
215
+ path: '/users/[id]',
216
+ response: {
217
+ id: t.number,
218
+ name: t.string,
219
+ },
220
+ }));
221
+
222
+ await testWithClient(client, async () => {
223
+ const relay = getUser({ id: '123' });
224
+
225
+ await expect(relay).rejects.toThrow('Network connection failed');
226
+ expect(relay.isRejected).toBe(true);
227
+ expect(relay.error).toBe(error);
228
+ });
229
+ });
230
+
231
+ it('should handle malformed JSON responses', async () => {
232
+ mockFetch.get('/users/[id]', null, {
233
+ jsonError: new Error('Unexpected token in JSON'),
234
+ });
235
+
236
+ const getUser = query(t => ({
237
+ path: '/users/[id]',
238
+ response: {
239
+ id: t.number,
240
+ name: t.string,
241
+ },
242
+ }));
243
+
244
+ await testWithClient(client, async () => {
245
+ const relay = getUser({ id: '123' });
246
+
247
+ await expect(relay).rejects.toThrow('Unexpected token in JSON');
248
+
249
+ expect(relay.isRejected).toBe(true);
250
+ expect(relay.error).toBeInstanceOf(Error);
251
+ expect((relay.error as Error).message).toBe('Unexpected token in JSON');
252
+ });
253
+ });
254
+
255
+ it('should require QueryClient context', async () => {
256
+ const getUser = query(t => ({
257
+ path: '/users/[id]',
258
+ response: {
259
+ id: t.number,
260
+ name: t.string,
261
+ },
262
+ }));
263
+
264
+ // Call without context
265
+ expect(() => getUser({ id: '123' })).toThrow('QueryClient not found');
266
+ });
267
+ });
268
+
269
+ describe('Query Deduplication', () => {
270
+ it('should deduplicate identical queries', async () => {
271
+ mockFetch.get('/users/[id]', { id: 123, name: 'Test User' });
272
+
273
+ const getUser = query(t => ({
274
+ path: '/users/[id]',
275
+ response: {
276
+ id: t.number,
277
+ name: t.string,
278
+ },
279
+ }));
280
+
281
+ await testWithClient(client, async () => {
282
+ const relay1 = getUser({ id: '123' });
283
+ const relay2 = getUser({ id: '123' });
284
+ const relay3 = getUser({ id: '123' });
285
+
286
+ // Should return the same relay instance
287
+ expect(relay1).toBe(relay2);
288
+ expect(relay2).toBe(relay3);
289
+
290
+ await relay1;
291
+
292
+ // Should only fetch once
293
+ expect(mockFetch.calls).toHaveLength(1);
294
+ });
295
+ });
296
+
297
+ it('should create separate queries for different parameters', async () => {
298
+ // Mocks are matched in LIFO order (last added is matched first)
299
+ mockFetch.get('/users/1', { id: 1, name: 'User' });
300
+ mockFetch.get('/users/2', { id: 2, name: 'User' });
301
+
302
+ const getUser = query(t => ({
303
+ path: '/users/[id]',
304
+ response: {
305
+ id: t.number,
306
+ name: t.string,
307
+ },
308
+ }));
309
+
310
+ await testWithClient(client, async () => {
311
+ const relay1 = getUser({ id: '1' });
312
+ const relay2 = getUser({ id: '2' });
313
+
314
+ // Should be different relay instances
315
+ expect(relay1).not.toBe(relay2);
316
+
317
+ const [result1, result2] = await Promise.all([relay1, relay2]);
318
+
319
+ expect(result1.id).toBe(1);
320
+ expect(result2.id).toBe(2);
321
+ expect(mockFetch.calls).toHaveLength(2);
322
+ });
323
+ });
324
+ });
325
+
326
+ describe('Response Type Handling', () => {
327
+ it('should handle primitive response types', async () => {
328
+ mockFetch.get('/message', 'Hello, World!');
329
+
330
+ const getMessage = query(t => ({
331
+ path: '/message',
332
+ response: t.string,
333
+ }));
334
+
335
+ await testWithClient(client, async () => {
336
+ const relay = getMessage();
337
+ const result = await relay;
338
+
339
+ expect(result).toBe('Hello, World!');
340
+ });
341
+ });
342
+
343
+ it('should handle array responses', async () => {
344
+ mockFetch.get('/numbers', [1, 2, 3, 4, 5]);
345
+
346
+ const getNumbers = query(t => ({
347
+ path: '/numbers',
348
+ response: t.array(t.number),
349
+ }));
350
+
351
+ await testWithClient(client, async () => {
352
+ const relay = getNumbers();
353
+ const result = await relay;
354
+
355
+ expect(result).toEqual([1, 2, 3, 4, 5]);
356
+ });
357
+ });
358
+
359
+ it('should handle nested object responses', async () => {
360
+ mockFetch.get('/user', {
361
+ user: {
362
+ id: 1,
363
+ profile: {
364
+ name: 'Alice',
365
+ email: 'alice@example.com',
366
+ },
367
+ },
368
+ });
369
+
370
+ const getUser = query(t => ({
371
+ path: '/user',
372
+ response: {
373
+ user: t.object({
374
+ id: t.number,
375
+ profile: t.object({
376
+ name: t.string,
377
+ email: t.string,
378
+ }),
379
+ }),
380
+ },
381
+ }));
382
+
383
+ await testWithClient(client, async () => {
384
+ const relay = getUser();
385
+ const result = await relay;
386
+
387
+ expect(result.user.profile.name).toBe('Alice');
388
+ expect(result.user.profile.email).toBe('alice@example.com');
389
+ });
390
+ });
391
+ });
392
+
393
+ describe('Entity Handling', () => {
394
+ it('should handle entity responses', async () => {
395
+ const User = entity(() => ({
396
+ __typename: t.typename('User'),
397
+ id: t.id,
398
+ name: t.string,
399
+ email: t.string,
400
+ }));
401
+
402
+ mockFetch.get('/users/[id]', {
403
+ user: {
404
+ __typename: 'User',
405
+ id: 1,
406
+ name: 'Alice',
407
+ email: 'alice@example.com',
408
+ },
409
+ });
410
+
411
+ const getUser = query(t => ({
412
+ path: '/users/[id]',
413
+ response: {
414
+ user: User,
415
+ },
416
+ }));
417
+
418
+ await testWithClient(client, async () => {
419
+ const relay = getUser({ id: '1' });
420
+ const result = await relay;
421
+
422
+ expect(result.user.name).toBe('Alice');
423
+ expect(result.user.email).toBe('alice@example.com');
424
+
425
+ // Verify entity was cached
426
+ expect(getEntityMapSize(client)).toBe(1);
427
+ });
428
+ });
429
+
430
+ it('should handle array of entities', async () => {
431
+ const User = entity(() => ({
432
+ __typename: t.typename('User'),
433
+ id: t.id,
434
+ name: t.string,
435
+ }));
436
+
437
+ mockFetch.get('/users', {
438
+ users: [
439
+ { __typename: 'User', id: 1, name: 'Alice' },
440
+ { __typename: 'User', id: 2, name: 'Bob' },
441
+ { __typename: 'User', id: 3, name: 'Charlie' },
442
+ ],
443
+ });
444
+
445
+ const listUsers = query(t => ({
446
+ path: '/users',
447
+ response: {
448
+ users: t.array(User),
449
+ },
450
+ }));
451
+
452
+ await testWithClient(client, async () => {
453
+ const relay = listUsers();
454
+ const result = await relay;
455
+
456
+ expect(result.users).toHaveLength(3);
457
+ expect(result.users[0].name).toBe('Alice');
458
+ expect(result.users[1].name).toBe('Bob');
459
+ expect(result.users[2].name).toBe('Charlie');
460
+
461
+ // Verify all entities were cached
462
+ expect(getEntityMapSize(client)).toBe(3);
463
+ });
464
+ });
465
+
466
+ it('should deduplicate entities across queries', async () => {
467
+ const User = entity(() => ({
468
+ __typename: t.typename('User'),
469
+ id: t.id,
470
+ name: t.string,
471
+ }));
472
+
473
+ mockFetch.get('/users/[id]', {
474
+ user: { __typename: 'User', id: 1, name: 'Alice' },
475
+ });
476
+ mockFetch.get('/users', {
477
+ users: [{ __typename: 'User', id: 1, name: 'Alice' }],
478
+ });
479
+
480
+ await testWithClient(client, async () => {
481
+ const getUser = query(t => ({
482
+ path: '/users/[id]',
483
+ response: {
484
+ user: User,
485
+ },
486
+ }));
487
+
488
+ const listUsers = query(t => ({
489
+ path: '/users',
490
+ response: {
491
+ users: t.array(User),
492
+ },
493
+ }));
494
+
495
+ const relay1 = getUser({ id: '1' });
496
+ const result1 = await relay1;
497
+
498
+ const relay2 = listUsers();
499
+ const result2 = await relay2;
500
+
501
+ // Should be the same entity proxy
502
+ expect(result1.user).toBe(result2.users[0]);
503
+ });
504
+ });
505
+ });
506
+
507
+ describe('Optional Parameters', () => {
508
+ it('should handle optional search parameters', async () => {
509
+ mockFetch.get('/users', { users: [] });
510
+ mockFetch.get('/users', { users: [] });
511
+ mockFetch.get('/users', { users: [] });
512
+
513
+ await testWithClient(client, async () => {
514
+ const listUsers = query(t => ({
515
+ path: '/users',
516
+ searchParams: {
517
+ page: t.union(t.number, t.undefined),
518
+ limit: t.union(t.number, t.undefined),
519
+ },
520
+ response: {
521
+ users: t.array(t.object({ id: t.number, name: t.string })),
522
+ },
523
+ }));
524
+
525
+ // Call without optional params
526
+ const relay1 = listUsers({});
527
+ await relay1;
528
+
529
+ // Call with some params
530
+ const relay2 = listUsers({ page: 1 });
531
+ await relay2;
532
+
533
+ // Call with all params
534
+ const relay3 = listUsers({ page: 1, limit: 10 });
535
+ await relay3;
536
+
537
+ expect(mockFetch.calls).toHaveLength(3);
538
+ });
539
+ });
540
+ });
541
+
542
+ describe('Type Safety', () => {
543
+ it('should infer correct types for path parameters', async () => {
544
+ mockFetch.get('/items/[itemId]/details/[detailId]', { id: 1, name: 'Test' });
545
+
546
+ const getItem = query(t => ({
547
+ path: '/items/[itemId]/details/[detailId]',
548
+ response: {
549
+ id: t.number,
550
+ name: t.string,
551
+ },
552
+ }));
553
+
554
+ await testWithClient(client, async () => {
555
+ // TypeScript should require both path params
556
+ const relay = getItem({ itemId: '1', detailId: '2' });
557
+ await relay;
558
+
559
+ expect(mockFetch.calls[0].url).toContain('/items/1/details/2');
560
+ expect(mockFetch.calls[0].options.method).toBe('GET');
561
+ });
562
+ });
563
+ });
564
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { typeToString } from '../errors.js';
3
+ import { t } from '../typeDefs.js';
4
+
5
+ /**
6
+ * Unit tests for typeToString function
7
+ * Tests the debug logging representation of TypeDef types
8
+ */
9
+
10
+ describe('typeToString', () => {
11
+ describe('Primitive Types', () => {
12
+ it('should convert string type to "string"', () => {
13
+ expect(typeToString(t.string)).toBe('string');
14
+ });
15
+
16
+ it('should convert number type to "number"', () => {
17
+ expect(typeToString(t.number)).toBe('number');
18
+ });
19
+
20
+ it('should convert boolean type to "boolean"', () => {
21
+ expect(typeToString(t.boolean)).toBe('boolean');
22
+ });
23
+
24
+ it('should convert null type to "null"', () => {
25
+ expect(typeToString(t.null)).toBe('null');
26
+ });
27
+
28
+ it('should convert undefined type to "undefined"', () => {
29
+ expect(typeToString(t.undefined)).toBe('undefined');
30
+ });
31
+ });
32
+
33
+ describe('Constant Values', () => {
34
+ it('should convert string constant to quoted string', () => {
35
+ expect(typeToString(t.const('user'))).toBe('"user"');
36
+ });
37
+
38
+ it('should convert boolean constant to boolean string', () => {
39
+ expect(typeToString(t.const(true))).toBe('true');
40
+ expect(typeToString(t.const(false))).toBe('false');
41
+ });
42
+
43
+ it('should convert number constant to number string', () => {
44
+ const numConst = t.const(42);
45
+ expect(typeToString(numConst)).toBe('42');
46
+ });
47
+ });
48
+
49
+ describe('Union Types', () => {
50
+ it('should convert primitive union types', () => {
51
+ const unionType = t.union(t.string, t.number);
52
+ const result = typeToString(unionType);
53
+ // Union of primitives should show both types
54
+ expect(result).toMatch(/string.*number|number.*string/);
55
+ });
56
+
57
+ it('should convert union with null', () => {
58
+ const unionType = t.union(t.string, t.null);
59
+ const result = typeToString(unionType);
60
+ expect(result).toContain('null');
61
+ expect(result).toContain('string');
62
+ });
63
+
64
+ it('should convert union with undefined', () => {
65
+ const unionType = t.union(t.number, t.undefined);
66
+ const result = typeToString(unionType);
67
+ expect(result).toContain('undefined');
68
+ expect(result).toContain('number');
69
+ });
70
+
71
+ it('should convert value union with types', () => {
72
+ // Union with both types and values
73
+ const unionType = t.union(t.string, t.const('admin'));
74
+ const result = typeToString(unionType);
75
+ });
76
+ });
77
+
78
+ describe('Array Types', () => {
79
+ it('should convert array of primitives', () => {
80
+ expect(typeToString(t.array(t.string))).toBe('Array<string>');
81
+ expect(typeToString(t.array(t.number))).toBe('Array<number>');
82
+ });
83
+
84
+ it('should convert nested arrays', () => {
85
+ const nestedArray = t.array(t.array(t.string));
86
+ expect(typeToString(nestedArray)).toBe('Array<Array<string>>');
87
+ });
88
+ });
89
+
90
+ describe('Record Types', () => {
91
+ it('should convert record of primitives', () => {
92
+ expect(typeToString(t.record(t.string))).toBe('Record<string, string>');
93
+ expect(typeToString(t.record(t.number))).toBe('Record<string, number>');
94
+ });
95
+
96
+ it('should convert record of arrays', () => {
97
+ const recordOfArrays = t.record(t.array(t.string));
98
+ expect(typeToString(recordOfArrays)).toBe('Record<string, Array<string>>');
99
+ });
100
+ });
101
+
102
+ describe('Object Types', () => {
103
+ it('should convert object without typename', () => {
104
+ const obj = t.object({ name: t.string, age: t.number });
105
+ expect(typeToString(obj)).toBe('object');
106
+ });
107
+ });
108
+
109
+ describe('Complex Nested Types', () => {
110
+ it('should handle array of records', () => {
111
+ const type = t.array(t.record(t.string));
112
+ expect(typeToString(type)).toBe('Array<Record<string, string>>');
113
+ });
114
+
115
+ it('should handle record of arrays', () => {
116
+ const type = t.record(t.array(t.number));
117
+ expect(typeToString(type)).toBe('Record<string, Array<number>>');
118
+ });
119
+
120
+ it('should handle union of primitives with null and undefined', () => {
121
+ const type = t.union(t.string, t.number, t.null, t.undefined);
122
+ const result = typeToString(type);
123
+ expect(result).toContain('string');
124
+ expect(result).toContain('number');
125
+ expect(result).toContain('null');
126
+ expect(result).toContain('undefined');
127
+ });
128
+ });
129
+ });