@sylphx/lens-server 1.11.3 → 2.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.
@@ -1,2361 +1,424 @@
1
1
  /**
2
2
  * @sylphx/lens-server - Server Tests
3
3
  *
4
- * Tests for Lens server operations and GraphStateManager integration.
4
+ * Tests for the pure executor server.
5
+ * Server only does: getMetadata() and execute()
5
6
  */
6
7
 
7
8
  import { describe, expect, it } from "bun:test";
8
- import { entity, mutation, query, resolver, t } from "@sylphx/lens-core";
9
+ import { entity, mutation, query, router } from "@sylphx/lens-core";
9
10
  import { z } from "zod";
10
- import { createServer, type WebSocketLike } from "./create";
11
+ import { optimisticPlugin } from "../plugin/optimistic.js";
12
+ import { createApp } from "./create.js";
11
13
 
12
14
  // =============================================================================
13
- // Test Fixtures
15
+ // Test Entities
14
16
  // =============================================================================
15
17
 
16
- // Entities
17
18
  const User = entity("User", {
18
- id: t.id(),
19
- name: t.string(),
20
- email: t.string(),
21
- bio: t.string().nullable(),
19
+ id: z.string(),
20
+ name: z.string(),
21
+ email: z.string().optional(),
22
22
  });
23
23
 
24
- const Post = entity("Post", {
25
- id: t.id(),
26
- title: t.string(),
27
- content: t.string(),
28
- authorId: t.string(),
29
- });
30
-
31
- // Mock data
32
- const mockUsers = [
33
- { id: "user-1", name: "Alice", email: "alice@example.com", bio: "Developer" },
34
- { id: "user-2", name: "Bob", email: "bob@example.com", bio: "Designer" },
35
- ];
36
-
37
- const _mockPosts = [
38
- { id: "post-1", title: "Hello", content: "World", authorId: "user-1" },
39
- { id: "post-2", title: "Test", content: "Post", authorId: "user-1" },
40
- ];
41
-
42
- // Mock WebSocket factory
43
- function createMockWs(): WebSocketLike & { messages: string[] } {
44
- const messages: string[] = [];
45
- return {
46
- messages,
47
- send: (data: string) => messages.push(data),
48
- close: () => {},
49
- onmessage: null,
50
- onclose: null,
51
- onerror: null,
52
- };
53
- }
54
-
55
24
  // =============================================================================
56
- // Test: Server Creation
25
+ // Test Queries and Mutations
57
26
  // =============================================================================
58
27
 
59
- describe("createServer", () => {
60
- it("creates a server instance", () => {
61
- const server = createServer({
62
- entities: { User, Post },
63
- });
28
+ const getUser = query()
29
+ .input(z.object({ id: z.string() }))
30
+ .returns(User)
31
+ .resolve(({ input }) => ({
32
+ id: input.id,
33
+ name: "Test User",
34
+ email: "test@example.com",
35
+ }));
64
36
 
65
- expect(server).toBeDefined();
66
- expect(typeof server.executeQuery).toBe("function");
67
- expect(typeof server.executeMutation).toBe("function");
68
- expect(typeof server.handleWebSocket).toBe("function");
69
- expect(typeof server.handleRequest).toBe("function");
70
- expect(typeof server.getStateManager).toBe("function");
71
- });
37
+ const getUsers = query().resolve(() => [
38
+ { id: "1", name: "User 1" },
39
+ { id: "2", name: "User 2" },
40
+ ]);
72
41
 
73
- it("throws for invalid query definition", () => {
74
- expect(() =>
75
- createServer({
76
- entities: { User },
77
- queries: {
78
- invalidQuery: { notAQuery: true } as never,
79
- },
80
- }),
81
- ).toThrow("Invalid query definition: invalidQuery");
82
- });
42
+ const createUser = mutation()
43
+ .input(z.object({ name: z.string(), email: z.string().optional() }))
44
+ .returns(User)
45
+ .resolve(({ input }) => ({
46
+ id: "new-id",
47
+ name: input.name,
48
+ email: input.email,
49
+ }));
83
50
 
84
- it("throws for invalid mutation definition", () => {
85
- expect(() =>
86
- createServer({
87
- entities: { User },
88
- mutations: {
89
- invalidMutation: { notAMutation: true } as never,
90
- },
91
- }),
92
- ).toThrow("Invalid mutation definition: invalidMutation");
93
- });
94
- });
51
+ const updateUser = mutation()
52
+ .input(z.object({ id: z.string(), name: z.string().optional() }))
53
+ .returns(User)
54
+ .resolve(({ input }) => ({
55
+ id: input.id,
56
+ name: input.name ?? "Updated",
57
+ }));
58
+
59
+ const deleteUser = mutation()
60
+ .input(z.object({ id: z.string() }))
61
+ .resolve(() => ({ success: true }));
95
62
 
96
63
  // =============================================================================
97
- // Test: Query Execution
64
+ // createApp Tests
98
65
  // =============================================================================
99
66
 
100
- describe("executeQuery", () => {
101
- it("executes a simple query", async () => {
102
- const getUsers = query()
103
- .returns([User])
104
- .resolve(() => mockUsers);
105
-
106
- const server = createServer({
107
- entities: { User },
108
- queries: { getUsers },
109
- });
110
-
111
- const result = await server.executeQuery("getUsers");
112
- expect(result).toEqual(mockUsers);
113
- });
114
-
115
- it("executes a query with input", async () => {
116
- const getUser = query()
117
- .input(z.object({ id: z.string() }))
118
- .returns(User)
119
- .resolve(({ input }) => {
120
- return mockUsers.find((u) => u.id === input.id) ?? null;
121
- });
122
-
123
- const server = createServer({
124
- entities: { User },
125
- queries: { getUser },
126
- });
127
-
128
- const result = await server.executeQuery("getUser", { id: "user-1" });
129
- expect(result).toEqual(mockUsers[0]);
130
- });
131
-
132
- it("validates query input", async () => {
133
- const getUser = query()
134
- .input(z.object({ id: z.string() }))
135
- .returns(User)
136
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
137
-
138
- const server = createServer({
67
+ describe("createApp", () => {
68
+ it("creates a server instance", () => {
69
+ const server = createApp({
139
70
  entities: { User },
140
71
  queries: { getUser },
72
+ mutations: { createUser },
141
73
  });
142
74
 
143
- await expect(server.executeQuery("getUser", { id: 123 as unknown as string })).rejects.toThrow("Invalid input");
144
- });
145
-
146
- it("throws for unknown query", async () => {
147
- const server = createServer({
148
- entities: { User },
149
- queries: {},
150
- });
151
-
152
- await expect(server.executeQuery("unknownQuery")).rejects.toThrow("Query not found: unknownQuery");
75
+ expect(server).toBeDefined();
76
+ expect(typeof server.getMetadata).toBe("function");
77
+ expect(typeof server.execute).toBe("function");
153
78
  });
154
- });
155
79
 
156
- // =============================================================================
157
- // Test: Mutation Execution
158
- // =============================================================================
159
-
160
- describe("executeMutation", () => {
161
- it("executes a simple mutation", async () => {
162
- const createUser = mutation()
163
- .input(z.object({ name: z.string(), email: z.string() }))
164
- .returns(User)
165
- .resolve(({ input }) => ({
166
- id: "user-new",
167
- name: input.name,
168
- email: input.email,
169
- }));
170
-
171
- const server = createServer({
172
- entities: { User },
173
- mutations: { createUser },
80
+ it("creates server with router", () => {
81
+ const appRouter = router({
82
+ user: {
83
+ get: getUser,
84
+ create: createUser,
85
+ },
174
86
  });
175
87
 
176
- const result = await server.executeMutation("createUser", {
177
- name: "Charlie",
178
- email: "charlie@example.com",
179
- });
88
+ const server = createApp({ router: appRouter });
180
89
 
181
- expect(result).toEqual({
182
- id: "user-new",
183
- name: "Charlie",
184
- email: "charlie@example.com",
185
- });
90
+ expect(server).toBeDefined();
91
+ const metadata = server.getMetadata();
92
+ expect(metadata.operations.user).toBeDefined();
186
93
  });
187
94
 
188
- it("validates mutation input", async () => {
189
- const createUser = mutation()
190
- .input(z.object({ name: z.string(), email: z.string().email() }))
191
- .returns(User)
192
- .resolve(({ input }) => ({ id: "new", ...input }));
193
-
194
- const server = createServer({
195
- entities: { User },
196
- mutations: { createUser },
95
+ it("creates server with custom version", () => {
96
+ const server = createApp({
97
+ queries: { getUser },
98
+ version: "2.0.0",
197
99
  });
198
100
 
199
- await expect(server.executeMutation("createUser", { name: "Test", email: "invalid-email" })).rejects.toThrow(
200
- "Invalid input",
201
- );
101
+ const metadata = server.getMetadata();
102
+ expect(metadata.version).toBe("2.0.0");
202
103
  });
203
104
 
204
- it("throws for unknown mutation", async () => {
205
- const server = createServer({
206
- entities: { User },
207
- mutations: {},
208
- });
105
+ it("creates server with empty config", () => {
106
+ const server = createApp({});
209
107
 
210
- await expect(server.executeMutation("unknownMutation", {})).rejects.toThrow("Mutation not found: unknownMutation");
108
+ expect(server).toBeDefined();
109
+ const metadata = server.getMetadata();
110
+ expect(metadata.version).toBe("1.0.0");
111
+ expect(metadata.operations).toEqual({});
211
112
  });
212
113
  });
213
114
 
214
115
  // =============================================================================
215
- // Test: WebSocket Protocol
116
+ // getMetadata Tests
216
117
  // =============================================================================
217
118
 
218
- describe("WebSocket Protocol", () => {
219
- it("handles handshake message", () => {
220
- const server = createServer({
221
- entities: { User },
222
- queries: {},
223
- mutations: {},
224
- version: "2.1.0",
119
+ describe("getMetadata", () => {
120
+ it("returns correct metadata structure", () => {
121
+ const server = createApp({
122
+ queries: { getUser, getUsers },
123
+ mutations: { createUser, updateUser },
124
+ version: "1.2.3",
225
125
  });
226
126
 
227
- const ws = createMockWs();
228
- server.handleWebSocket(ws);
229
-
230
- // Simulate handshake
231
- ws.onmessage?.({ data: JSON.stringify({ type: "handshake", id: "hs-1" }) });
127
+ const metadata = server.getMetadata();
232
128
 
233
- expect(ws.messages.length).toBe(1);
234
- const response = JSON.parse(ws.messages[0]);
235
- expect(response.type).toBe("handshake");
236
- expect(response.id).toBe("hs-1");
237
- expect(response.version).toBe("2.1.0");
129
+ expect(metadata.version).toBe("1.2.3");
130
+ expect(metadata.operations.getUser).toEqual({ type: "query" });
131
+ expect(metadata.operations.getUsers).toEqual({ type: "query" });
132
+ expect(metadata.operations.createUser.type).toBe("mutation");
133
+ expect(metadata.operations.updateUser.type).toBe("mutation");
238
134
  });
239
135
 
240
- it("handles query message", async () => {
241
- const getUsers = query()
242
- .returns([User])
243
- .resolve(() => mockUsers);
244
-
245
- const server = createServer({
246
- entities: { User },
247
- queries: { getUsers },
136
+ it("includes optimistic hints for mutations with optimisticPlugin", () => {
137
+ const server = createApp({
138
+ mutations: { createUser, updateUser, deleteUser },
139
+ plugins: [optimisticPlugin()],
248
140
  });
249
141
 
250
- const ws = createMockWs();
251
- server.handleWebSocket(ws);
252
-
253
- // Simulate query
254
- ws.onmessage?.({ data: JSON.stringify({ type: "query", id: "q-1", operation: "getUsers" }) });
255
-
256
- await new Promise((r) => setTimeout(r, 20));
142
+ const metadata = server.getMetadata();
257
143
 
258
- expect(ws.messages.length).toBe(1);
259
- const response = JSON.parse(ws.messages[0]);
260
- expect(response.type).toBe("result");
261
- expect(response.id).toBe("q-1");
262
- expect(response.data).toEqual(mockUsers);
144
+ // Auto-derived from naming convention when using optimisticPlugin
145
+ expect(metadata.operations.createUser.optimistic).toBeDefined();
146
+ expect(metadata.operations.updateUser.optimistic).toBeDefined();
147
+ expect(metadata.operations.deleteUser.optimistic).toBeDefined();
263
148
  });
264
149
 
265
- it("handles mutation message", async () => {
266
- const createUser = mutation()
267
- .input(z.object({ name: z.string() }))
268
- .returns(User)
269
- .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
270
-
271
- const server = createServer({
272
- entities: { User },
273
- mutations: { createUser },
274
- });
275
-
276
- const ws = createMockWs();
277
- server.handleWebSocket(ws);
278
-
279
- // Simulate mutation
280
- ws.onmessage?.({
281
- data: JSON.stringify({
282
- type: "mutation",
283
- id: "m-1",
284
- operation: "createUser",
285
- input: { name: "Test" },
286
- }),
150
+ it("does not include optimistic hints without optimisticPlugin", () => {
151
+ const server = createApp({
152
+ mutations: { createUser, updateUser, deleteUser },
287
153
  });
288
154
 
289
- await new Promise((r) => setTimeout(r, 20));
155
+ const metadata = server.getMetadata();
290
156
 
291
- expect(ws.messages.length).toBe(1);
292
- const response = JSON.parse(ws.messages[0]);
293
- expect(response.type).toBe("result");
294
- expect(response.id).toBe("m-1");
295
- expect(response.data).toEqual({ id: "new", name: "Test", email: "" });
157
+ // Without plugin, no optimistic hints
158
+ expect(metadata.operations.createUser.optimistic).toBeUndefined();
159
+ expect(metadata.operations.updateUser.optimistic).toBeUndefined();
160
+ expect(metadata.operations.deleteUser.optimistic).toBeUndefined();
296
161
  });
297
162
 
298
- it("handles parse error", () => {
299
- const server = createServer({
300
- entities: { User },
163
+ it("handles nested router paths", () => {
164
+ const appRouter = router({
165
+ user: {
166
+ get: getUser,
167
+ create: createUser,
168
+ profile: {
169
+ update: updateUser,
170
+ },
171
+ },
301
172
  });
302
173
 
303
- const ws = createMockWs();
304
- server.handleWebSocket(ws);
305
-
306
- // Send invalid JSON
307
- ws.onmessage?.({ data: "invalid json" });
174
+ const server = createApp({ router: appRouter });
175
+ const metadata = server.getMetadata();
308
176
 
309
- expect(ws.messages.length).toBe(1);
310
- const response = JSON.parse(ws.messages[0]);
311
- expect(response.type).toBe("error");
312
- expect(response.error.code).toBe("PARSE_ERROR");
177
+ expect(metadata.operations.user).toBeDefined();
178
+ expect((metadata.operations.user as Record<string, unknown>).get).toEqual({ type: "query" });
179
+ expect((metadata.operations.user as Record<string, unknown>).create).toBeDefined();
313
180
  });
314
181
  });
315
182
 
316
183
  // =============================================================================
317
- // Test: Subscribe Protocol (Field-Level)
184
+ // execute Tests
318
185
  // =============================================================================
319
186
 
320
- describe("Subscribe Protocol", () => {
321
- it("handles subscribe message", async () => {
322
- const getUser = query()
323
- .input(z.object({ id: z.string() }))
324
- .returns(User)
325
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
326
-
327
- const server = createServer({
328
- entities: { User },
329
- queries: { getUser },
330
- });
331
-
332
- const ws = createMockWs();
333
- server.handleWebSocket(ws);
334
-
335
- // Subscribe to user
336
- ws.onmessage?.({
337
- data: JSON.stringify({
338
- type: "subscribe",
339
- id: "sub-1",
340
- operation: "getUser",
341
- input: { id: "user-1" },
342
- fields: ["name", "email"],
343
- }),
344
- });
345
-
346
- await new Promise((r) => setTimeout(r, 50));
347
-
348
- // Should receive messages (either from GraphStateManager or operation-level)
349
- expect(ws.messages.length).toBeGreaterThan(0);
350
-
351
- // Find the operation-level data message (has subscription id)
352
- const dataMessage = ws.messages.map((m) => JSON.parse(m)).find((m) => m.type === "data" && m.id === "sub-1");
353
-
354
- // Should have received operation-level data
355
- expect(dataMessage).toBeDefined();
356
- expect(dataMessage.data).toMatchObject({ name: "Alice" });
357
- });
358
-
359
- it("handles unsubscribe message", async () => {
360
- const getUser = query()
361
- .input(z.object({ id: z.string() }))
362
- .returns(User)
363
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
364
-
365
- const server = createServer({
366
- entities: { User },
187
+ describe("execute", () => {
188
+ it("executes query successfully", async () => {
189
+ const server = createApp({
367
190
  queries: { getUser },
368
191
  });
369
192
 
370
- const ws = createMockWs();
371
- server.handleWebSocket(ws);
372
-
373
- // Subscribe
374
- ws.onmessage?.({
375
- data: JSON.stringify({
376
- type: "subscribe",
377
- id: "sub-1",
378
- operation: "getUser",
379
- input: { id: "user-1" },
380
- fields: "*",
381
- }),
193
+ const result = await server.execute({
194
+ path: "getUser",
195
+ input: { id: "123" },
382
196
  });
383
197
 
384
- await new Promise((r) => setTimeout(r, 20));
385
-
386
- // Unsubscribe
387
- ws.onmessage?.({
388
- data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
198
+ expect(result.data).toEqual({
199
+ id: "123",
200
+ name: "Test User",
201
+ email: "test@example.com",
389
202
  });
390
-
391
- // Should not throw
392
- expect(true).toBe(true);
203
+ expect(result.error).toBeUndefined();
393
204
  });
394
205
 
395
- it("handles updateFields message", async () => {
396
- const getUser = query()
397
- .input(z.object({ id: z.string() }))
398
- .returns(User)
399
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
400
-
401
- const server = createServer({
402
- entities: { User },
403
- queries: { getUser },
404
- });
405
-
406
- const ws = createMockWs();
407
- server.handleWebSocket(ws);
408
-
409
- // Subscribe with partial fields
410
- ws.onmessage?.({
411
- data: JSON.stringify({
412
- type: "subscribe",
413
- id: "sub-1",
414
- operation: "getUser",
415
- input: { id: "user-1" },
416
- fields: ["name"],
417
- }),
206
+ it("executes mutation successfully", async () => {
207
+ const server = createApp({
208
+ mutations: { createUser },
418
209
  });
419
210
 
420
- await new Promise((r) => setTimeout(r, 20));
421
-
422
- // Update fields
423
- ws.onmessage?.({
424
- data: JSON.stringify({
425
- type: "updateFields",
426
- id: "sub-1",
427
- addFields: ["email"],
428
- removeFields: [],
429
- }),
211
+ const result = await server.execute({
212
+ path: "createUser",
213
+ input: { name: "New User", email: "new@example.com" },
430
214
  });
431
215
 
432
- // Should not throw
433
- expect(true).toBe(true);
434
- });
435
- });
436
-
437
- // =============================================================================
438
- // Test: GraphStateManager Integration
439
- // =============================================================================
440
-
441
- describe("GraphStateManager Integration", () => {
442
- it("provides access to state manager", () => {
443
- const server = createServer({
444
- entities: { User },
216
+ expect(result.data).toEqual({
217
+ id: "new-id",
218
+ name: "New User",
219
+ email: "new@example.com",
445
220
  });
446
-
447
- const stateManager = server.getStateManager();
448
- expect(stateManager).toBeDefined();
449
- expect(typeof stateManager.emit).toBe("function");
450
- expect(typeof stateManager.subscribe).toBe("function");
221
+ expect(result.error).toBeUndefined();
451
222
  });
452
223
 
453
- it("tracks client state after subscribe", async () => {
454
- const getUser = query()
455
- .input(z.object({ id: z.string() }))
456
- .returns(User)
457
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
458
-
459
- const server = createServer({
460
- entities: { User },
224
+ it("returns error for unknown operation", async () => {
225
+ const server = createApp({
461
226
  queries: { getUser },
462
227
  });
463
228
 
464
- const stateManager = server.getStateManager();
465
-
466
- const ws = createMockWs();
467
- server.handleWebSocket(ws);
468
-
469
- // Subscribe
470
- ws.onmessage?.({
471
- data: JSON.stringify({
472
- type: "subscribe",
473
- id: "sub-1",
474
- operation: "getUser",
475
- input: { id: "user-1" },
476
- fields: "*",
477
- }),
478
- });
479
-
480
- await new Promise((r) => setTimeout(r, 50));
481
-
482
- // State manager should have the client registered
483
- const stats = stateManager.getStats();
484
- expect(stats.clients).toBe(1);
485
- });
486
-
487
- it("removes client state on disconnect", async () => {
488
- const server = createServer({
489
- entities: { User },
229
+ const result = await server.execute({
230
+ path: "unknownOperation",
231
+ input: {},
490
232
  });
491
233
 
492
- const stateManager = server.getStateManager();
493
-
494
- const ws = createMockWs();
495
- server.handleWebSocket(ws);
496
-
497
- // Simulate disconnect
498
- ws.onclose?.();
499
-
500
- const stats = stateManager.getStats();
501
- expect(stats.clients).toBe(0);
234
+ expect(result.data).toBeUndefined();
235
+ expect(result.error).toBeInstanceOf(Error);
236
+ expect(result.error?.message).toContain("not found");
502
237
  });
503
- });
504
-
505
- // =============================================================================
506
- // Test: HTTP Handler
507
- // =============================================================================
508
-
509
- describe("handleRequest", () => {
510
- it("handles query request", async () => {
511
- const getUsers = query()
512
- .returns([User])
513
- .resolve(() => mockUsers);
514
238
 
515
- const server = createServer({
516
- entities: { User },
517
- queries: { getUsers },
239
+ it("returns error for invalid input", async () => {
240
+ const server = createApp({
241
+ queries: { getUser },
518
242
  });
519
243
 
520
- const request = new Request("http://localhost/api", {
521
- method: "POST",
522
- headers: { "Content-Type": "application/json" },
523
- body: JSON.stringify({ type: "query", operation: "getUsers" }),
244
+ const result = await server.execute({
245
+ path: "getUser",
246
+ input: { invalid: true }, // Missing required 'id'
524
247
  });
525
248
 
526
- const response = await server.handleRequest(request);
527
- expect(response.status).toBe(200);
528
-
529
- const body = await response.json();
530
- expect(body.data).toEqual(mockUsers);
249
+ expect(result.data).toBeUndefined();
250
+ expect(result.error).toBeInstanceOf(Error);
531
251
  });
532
252
 
533
- it("handles mutation request", async () => {
534
- const createUser = mutation()
535
- .input(z.object({ name: z.string() }))
536
- .returns(User)
537
- .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
538
-
539
- const server = createServer({
540
- entities: { User },
541
- mutations: { createUser },
542
- });
543
-
544
- const request = new Request("http://localhost/api", {
545
- method: "POST",
546
- headers: { "Content-Type": "application/json" },
547
- body: JSON.stringify({ type: "mutation", operation: "createUser", input: { name: "Test" } }),
253
+ it("executes router operations with dot notation", async () => {
254
+ const appRouter = router({
255
+ user: {
256
+ get: getUser,
257
+ create: createUser,
258
+ },
548
259
  });
549
260
 
550
- const response = await server.handleRequest(request);
551
- expect(response.status).toBe(200);
552
-
553
- const body = await response.json();
554
- expect(body.data).toEqual({ id: "new", name: "Test", email: "" });
555
- });
261
+ const server = createApp({ router: appRouter });
556
262
 
557
- it("rejects non-POST requests", async () => {
558
- const server = createServer({
559
- entities: { User },
263
+ const queryResult = await server.execute({
264
+ path: "user.get",
265
+ input: { id: "456" },
560
266
  });
561
267
 
562
- const request = new Request("http://localhost/api", { method: "GET" });
563
- const response = await server.handleRequest(request);
564
-
565
- expect(response.status).toBe(405);
566
- });
567
- });
568
-
569
- // =============================================================================
570
- // Test: Streaming (Async Generator) Support
571
- // =============================================================================
572
-
573
- describe("Streaming Support", () => {
574
- it("handles async generator query", async () => {
575
- const streamQuery = query()
576
- .returns(User)
577
- .resolve(async function* () {
578
- yield mockUsers[0];
579
- yield mockUsers[1];
580
- });
581
-
582
- const server = createServer({
583
- entities: { User },
584
- queries: { streamQuery },
268
+ expect(queryResult.data).toEqual({
269
+ id: "456",
270
+ name: "Test User",
271
+ email: "test@example.com",
585
272
  });
586
273
 
587
- // executeQuery returns first value
588
- const result = await server.executeQuery("streamQuery");
589
- expect(result).toEqual(mockUsers[0]);
590
- });
591
-
592
- it("streams values via WebSocket subscribe", async () => {
593
- let yieldCount = 0;
594
-
595
- const streamQuery = query()
596
- .returns(User)
597
- .resolve(async function* () {
598
- yieldCount++;
599
- yield mockUsers[0];
600
- yieldCount++;
601
- yield mockUsers[1];
602
- });
603
-
604
- const server = createServer({
605
- entities: { User },
606
- queries: { streamQuery },
274
+ const mutationResult = await server.execute({
275
+ path: "user.create",
276
+ input: { name: "Router User" },
607
277
  });
608
278
 
609
- const ws = createMockWs();
610
- server.handleWebSocket(ws);
611
-
612
- // Subscribe to stream
613
- ws.onmessage?.({
614
- data: JSON.stringify({
615
- type: "subscribe",
616
- id: "sub-1",
617
- operation: "streamQuery",
618
- fields: "*",
619
- }),
279
+ expect(mutationResult.data).toEqual({
280
+ id: "new-id",
281
+ name: "Router User",
282
+ email: undefined,
620
283
  });
621
-
622
- await new Promise((r) => setTimeout(r, 50));
623
-
624
- expect(yieldCount).toBe(2);
625
- expect(ws.messages.length).toBeGreaterThanOrEqual(1);
626
284
  });
627
- });
628
-
629
- // =============================================================================
630
- // Test: Minimum Transfer (Diff Computation)
631
- // =============================================================================
632
285
 
633
- describe("Minimum Transfer", () => {
634
- it("sends initial data on subscribe", async () => {
635
- let _emitFn: ((data: unknown) => void) | null = null;
636
-
637
- const liveQuery = query()
638
- .returns(User)
639
- .resolve(({ emit }) => {
640
- _emitFn = emit;
641
- return { id: "1", name: "Alice", email: "alice@example.com" };
286
+ it("handles resolver errors gracefully", async () => {
287
+ const errorQuery = query()
288
+ .input(z.object({ id: z.string() }))
289
+ .resolve(() => {
290
+ throw new Error("Resolver error");
642
291
  });
643
292
 
644
- const server = createServer({
645
- entities: { User },
646
- queries: { liveQuery },
293
+ const server = createApp({
294
+ queries: { errorQuery },
647
295
  });
648
296
 
649
- const ws = createMockWs();
650
- server.handleWebSocket(ws);
651
-
652
- ws.onmessage?.({
653
- data: JSON.stringify({
654
- type: "subscribe",
655
- id: "sub-1",
656
- operation: "liveQuery",
657
- fields: "*",
658
- }),
297
+ const result = await server.execute({
298
+ path: "errorQuery",
299
+ input: { id: "1" },
659
300
  });
660
301
 
661
- await new Promise((r) => setTimeout(r, 50));
662
-
663
- // First message should have data
664
- expect(ws.messages.length).toBeGreaterThan(0);
665
- const firstUpdate = JSON.parse(ws.messages[0]);
666
- // Can be "data" or "update" with value strategy for initial
667
- expect(["data", "update"]).toContain(firstUpdate.type);
668
- // Verify we got the data
669
- if (firstUpdate.type === "data") {
670
- expect(firstUpdate.data).toMatchObject({ name: "Alice" });
671
- } else {
672
- expect(firstUpdate.updates).toBeDefined();
673
- }
302
+ expect(result.data).toBeUndefined();
303
+ expect(result.error).toBeInstanceOf(Error);
304
+ expect(result.error?.message).toBe("Resolver error");
674
305
  });
675
306
 
676
- it("sends updates via emit", async () => {
677
- let emitFn: ((data: unknown) => void) | null = null;
678
-
679
- const liveQuery = query()
680
- .returns(User)
681
- .resolve(({ emit }) => {
682
- emitFn = emit;
683
- return { id: "1", name: "Alice", email: "alice@example.com" };
684
- });
685
-
686
- const server = createServer({
687
- entities: { User },
688
- queries: { liveQuery },
307
+ it("executes query without input", async () => {
308
+ const server = createApp({
309
+ queries: { getUsers },
689
310
  });
690
311
 
691
- const ws = createMockWs();
692
- server.handleWebSocket(ws);
693
-
694
- ws.onmessage?.({
695
- data: JSON.stringify({
696
- type: "subscribe",
697
- id: "sub-1",
698
- operation: "liveQuery",
699
- fields: "*",
700
- }),
312
+ const result = await server.execute({
313
+ path: "getUsers",
701
314
  });
702
315
 
703
- await new Promise((r) => setTimeout(r, 50));
704
-
705
- const initialCount = ws.messages.length;
706
-
707
- // Emit update with changed data
708
- emitFn?.({ id: "1", name: "Bob", email: "bob@example.com" });
709
-
710
- await new Promise((r) => setTimeout(r, 50));
711
-
712
- // Should have received additional messages
713
- expect(ws.messages.length).toBeGreaterThanOrEqual(initialCount);
316
+ expect(result.data).toHaveLength(2);
714
317
  });
715
318
  });
716
319
 
717
320
  // =============================================================================
718
- // Test: onCleanup
321
+ // Context Tests
719
322
  // =============================================================================
720
323
 
721
- describe("onCleanup", () => {
722
- it("calls cleanup function on unsubscribe", async () => {
723
- let cleanedUp = false;
324
+ describe("context", () => {
325
+ it("passes context to resolvers", async () => {
326
+ let capturedContext: unknown = null;
724
327
 
725
- const liveQuery = query()
726
- .returns(User)
328
+ const contextQuery = query()
329
+ .input(z.object({ id: z.string() }))
727
330
  .resolve(({ ctx }) => {
728
- ctx.onCleanup(() => {
729
- cleanedUp = true;
730
- });
731
- return mockUsers[0];
331
+ capturedContext = ctx;
332
+ return { id: "1", name: "test" };
732
333
  });
733
334
 
734
- const server = createServer({
735
- entities: { User },
736
- queries: { liveQuery },
737
- });
738
-
739
- const ws = createMockWs();
740
- server.handleWebSocket(ws);
741
-
742
- // Subscribe
743
- ws.onmessage?.({
744
- data: JSON.stringify({
745
- type: "subscribe",
746
- id: "sub-1",
747
- operation: "liveQuery",
748
- fields: "*",
749
- }),
335
+ const server = createApp({
336
+ queries: { contextQuery },
337
+ context: () => ({ userId: "user-123", role: "admin" }),
750
338
  });
751
339
 
752
- await new Promise((r) => setTimeout(r, 20));
753
-
754
- // Unsubscribe
755
- ws.onmessage?.({
756
- data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
757
- });
758
-
759
- expect(cleanedUp).toBe(true);
760
- });
761
-
762
- it("calls cleanup on client disconnect", async () => {
763
- let cleanedUp = false;
764
-
765
- const liveQuery = query()
766
- .returns(User)
767
- .resolve(({ ctx }) => {
768
- ctx.onCleanup(() => {
769
- cleanedUp = true;
770
- });
771
- return mockUsers[0];
772
- });
773
-
774
- const server = createServer({
775
- entities: { User },
776
- queries: { liveQuery },
340
+ await server.execute({
341
+ path: "contextQuery",
342
+ input: { id: "1" },
777
343
  });
778
344
 
779
- const ws = createMockWs();
780
- server.handleWebSocket(ws);
781
-
782
- // Subscribe
783
- ws.onmessage?.({
784
- data: JSON.stringify({
785
- type: "subscribe",
786
- id: "sub-1",
787
- operation: "liveQuery",
788
- fields: "*",
789
- }),
345
+ expect(capturedContext).toMatchObject({
346
+ userId: "user-123",
347
+ role: "admin",
790
348
  });
791
-
792
- await new Promise((r) => setTimeout(r, 20));
793
-
794
- // Disconnect
795
- ws.onclose?.();
796
-
797
- expect(cleanedUp).toBe(true);
798
349
  });
799
350
 
800
- it("allows cleanup removal via returned function", async () => {
801
- let cleanedUp = false;
351
+ it("supports async context factory", async () => {
352
+ let capturedContext: unknown = null;
802
353
 
803
- const liveQuery = query()
804
- .returns(User)
354
+ const contextQuery = query()
355
+ .input(z.object({ id: z.string() }))
805
356
  .resolve(({ ctx }) => {
806
- const remove = ctx.onCleanup(() => {
807
- cleanedUp = true;
808
- });
809
- // Remove the cleanup before unsubscribe
810
- remove();
811
- return mockUsers[0];
357
+ capturedContext = ctx;
358
+ return { id: "1", name: "test" };
812
359
  });
813
360
 
814
- const server = createServer({
815
- entities: { User },
816
- queries: { liveQuery },
361
+ const server = createApp({
362
+ queries: { contextQuery },
363
+ context: async () => {
364
+ await new Promise((r) => setTimeout(r, 10));
365
+ return { userId: "async-user" };
366
+ },
817
367
  });
818
368
 
819
- const ws = createMockWs();
820
- server.handleWebSocket(ws);
821
-
822
- // Subscribe
823
- ws.onmessage?.({
824
- data: JSON.stringify({
825
- type: "subscribe",
826
- id: "sub-1",
827
- operation: "liveQuery",
828
- fields: "*",
829
- }),
369
+ await server.execute({
370
+ path: "contextQuery",
371
+ input: { id: "1" },
830
372
  });
831
373
 
832
- await new Promise((r) => setTimeout(r, 20));
833
-
834
- // Unsubscribe
835
- ws.onmessage?.({
836
- data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
374
+ expect(capturedContext).toMatchObject({
375
+ userId: "async-user",
837
376
  });
838
-
839
- // Should not have cleaned up since we removed it
840
- expect(cleanedUp).toBe(false);
841
377
  });
842
378
  });
843
379
 
844
380
  // =============================================================================
845
- // Test: execute() method (for in-process transport)
381
+ // Selection Tests
846
382
  // =============================================================================
847
383
 
848
- describe("execute method", () => {
849
- it("executes a query operation", async () => {
850
- const getUsers = query()
851
- .returns([User])
852
- .resolve(() => mockUsers);
853
-
854
- const server = createServer({
855
- entities: { User },
856
- queries: { getUsers },
384
+ describe("selection", () => {
385
+ it("supports $select in input", async () => {
386
+ const server = createApp({
387
+ queries: { getUser },
857
388
  });
858
389
 
859
- const result = await server.execute({ path: "getUsers" });
860
- expect(result.data).toEqual(mockUsers);
861
- expect(result.error).toBeUndefined();
862
- });
863
-
864
- it("executes a mutation operation", async () => {
865
- const createUser = mutation()
866
- .input(z.object({ name: z.string() }))
867
- .returns(User)
868
- .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
869
-
870
- const server = createServer({
871
- entities: { User },
872
- mutations: { createUser },
390
+ const result = await server.execute({
391
+ path: "getUser",
392
+ input: {
393
+ id: "123",
394
+ $select: { name: true },
395
+ },
873
396
  });
874
397
 
875
- const result = await server.execute({ path: "createUser", input: { name: "Test" } });
876
- expect(result.data).toEqual({ id: "new", name: "Test", email: "" });
877
- expect(result.error).toBeUndefined();
878
- });
879
-
880
- it("returns error for unknown operation", async () => {
881
- const server = createServer({
882
- entities: { User },
398
+ expect(result.data).toEqual({
399
+ id: "123", // id always included
400
+ name: "Test User",
883
401
  });
884
-
885
- const result = await server.execute({ path: "unknownOp" });
886
- expect(result.data).toBeUndefined();
887
- expect(result.error).toBeDefined();
888
- expect(result.error?.message).toBe("Operation not found: unknownOp");
402
+ expect((result.data as Record<string, unknown>).email).toBeUndefined();
889
403
  });
404
+ });
890
405
 
891
- it("catches and returns errors from operations", async () => {
892
- const errorQuery = query()
893
- .returns(User)
894
- .resolve(() => {
895
- throw new Error("Test error");
896
- });
406
+ // =============================================================================
407
+ // Type Inference Tests
408
+ // =============================================================================
897
409
 
898
- const server = createServer({
899
- entities: { User },
900
- queries: { errorQuery },
901
- });
902
-
903
- const result = await server.execute({ path: "errorQuery" });
904
- expect(result.data).toBeUndefined();
905
- expect(result.error).toBeDefined();
906
- expect(result.error?.message).toBe("Test error");
907
- });
908
-
909
- it("converts non-Error exceptions to Error objects", async () => {
910
- const errorQuery = query()
911
- .returns(User)
912
- .resolve(() => {
913
- throw "String error";
914
- });
915
-
916
- const server = createServer({
917
- entities: { User },
918
- queries: { errorQuery },
919
- });
920
-
921
- const result = await server.execute({ path: "errorQuery" });
922
- expect(result.error).toBeDefined();
923
- expect(result.error?.message).toBe("String error");
924
- });
925
- });
926
-
927
- // =============================================================================
928
- // Test: getMetadata() and buildOperationsMap()
929
- // =============================================================================
930
-
931
- describe("getMetadata", () => {
932
- it("returns server metadata with version and operations", () => {
933
- const getUser = query()
934
- .returns(User)
935
- .resolve(() => mockUsers[0]);
936
-
937
- const createUser = mutation()
938
- .input(z.object({ name: z.string() }))
939
- .returns(User)
940
- .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
941
-
942
- const server = createServer({
943
- entities: { User },
944
- queries: { getUser },
945
- mutations: { createUser },
946
- version: "1.2.3",
947
- });
948
-
949
- const metadata = server.getMetadata();
950
- expect(metadata.version).toBe("1.2.3");
951
- expect(metadata.operations).toBeDefined();
952
- expect(metadata.operations.getUser).toEqual({ type: "query" });
953
- // createUser auto-derives optimistic "create" from naming convention (converted to Pipeline)
954
- expect((metadata.operations.createUser as any).type).toBe("mutation");
955
- expect((metadata.operations.createUser as any).optimistic.$pipe).toBeDefined();
956
- expect((metadata.operations.createUser as any).optimistic.$pipe[0].$do).toBe("entity.create");
957
- });
958
-
959
- it("builds nested operations map from namespaced routes", () => {
960
- const getUserQuery = query()
961
- .returns(User)
962
- .resolve(() => mockUsers[0]);
963
-
964
- const createUserMutation = mutation()
965
- .input(z.object({ name: z.string() }))
966
- .returns(User)
967
- .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
968
-
969
- const server = createServer({
970
- entities: { User },
971
- queries: { "user.get": getUserQuery },
972
- mutations: { "user.create": createUserMutation },
410
+ describe("type inference", () => {
411
+ it("infers types correctly", () => {
412
+ const server = createApp({
413
+ queries: { getUser },
414
+ mutations: { createUser },
973
415
  });
974
416
 
417
+ // Type check - if this compiles, types are working
975
418
  const metadata = server.getMetadata();
976
- expect(metadata.operations.user).toBeDefined();
977
- expect((metadata.operations.user as any).get).toEqual({ type: "query" });
978
- // Auto-derives optimistic "create" from naming convention (converted to Pipeline)
979
- expect((metadata.operations.user as any).create.type).toBe("mutation");
980
- expect((metadata.operations.user as any).create.optimistic.$pipe).toBeDefined();
981
- expect((metadata.operations.user as any).create.optimistic.$pipe[0].$do).toBe("entity.create");
982
- });
983
-
984
- it("includes optimistic config in mutation metadata", () => {
985
- const updateUser = mutation()
986
- .input(z.object({ id: z.string(), name: z.string() }))
987
- .returns(User)
988
- .optimistic("merge")
989
- .resolve(({ input }) => ({ id: input.id, name: input.name, email: "" }));
990
-
991
- const server = createServer({
992
- entities: { User },
993
- mutations: { updateUser },
994
- });
995
-
996
- const metadata = server.getMetadata();
997
- // Sugar "merge" is converted to Reify Pipeline
998
- expect(metadata.operations.updateUser).toEqual({
999
- type: "mutation",
1000
- optimistic: {
1001
- $pipe: [
1002
- {
1003
- $do: "entity.update",
1004
- $with: {
1005
- type: "User",
1006
- id: { $input: "id" },
1007
- name: { $input: "name" },
1008
- },
1009
- },
1010
- ],
1011
- },
1012
- });
1013
- });
1014
-
1015
- it("handles deeply nested namespaced operations", () => {
1016
- const deepQuery = query()
1017
- .returns(User)
1018
- .resolve(() => mockUsers[0]);
1019
-
1020
- const server = createServer({
1021
- entities: { User },
1022
- queries: { "api.v1.user.get": deepQuery },
1023
- });
1024
-
1025
- const metadata = server.getMetadata();
1026
- const operations = metadata.operations as any;
1027
- expect(operations.api.v1.user.get).toEqual({ type: "query" });
1028
- });
1029
- });
1030
-
1031
- // =============================================================================
1032
- // Test: HTTP handleRequest edge cases
1033
- // =============================================================================
1034
-
1035
- describe("handleRequest edge cases", () => {
1036
- it("returns metadata on GET /__lens/metadata", async () => {
1037
- const server = createServer({
1038
- entities: { User },
1039
- version: "1.0.0",
1040
- });
1041
-
1042
- const request = new Request("http://localhost/__lens/metadata", { method: "GET" });
1043
- const response = await server.handleRequest(request);
1044
-
1045
- expect(response.status).toBe(200);
1046
- const body = await response.json();
1047
- expect(body.version).toBe("1.0.0");
1048
- expect(body.operations).toBeDefined();
1049
- });
1050
-
1051
- it("returns 404 for unknown operation", async () => {
1052
- const server = createServer({
1053
- entities: { User },
1054
- });
1055
-
1056
- const request = new Request("http://localhost/api", {
1057
- method: "POST",
1058
- headers: { "Content-Type": "application/json" },
1059
- body: JSON.stringify({ operation: "unknownOp" }),
1060
- });
1061
-
1062
- const response = await server.handleRequest(request);
1063
- expect(response.status).toBe(404);
1064
-
1065
- const body = await response.json();
1066
- expect(body.error).toBe("Operation not found: unknownOp");
1067
- });
1068
-
1069
- it("returns 500 for operation errors", async () => {
1070
- const errorQuery = query()
1071
- .returns(User)
1072
- .resolve(() => {
1073
- throw new Error("Internal error");
1074
- });
1075
-
1076
- const server = createServer({
1077
- entities: { User },
1078
- queries: { errorQuery },
1079
- });
1080
-
1081
- const request = new Request("http://localhost/api", {
1082
- method: "POST",
1083
- headers: { "Content-Type": "application/json" },
1084
- body: JSON.stringify({ operation: "errorQuery" }),
1085
- });
1086
-
1087
- const response = await server.handleRequest(request);
1088
- expect(response.status).toBe(500);
1089
-
1090
- const body = await response.json();
1091
- expect(body.error).toContain("Internal error");
1092
- });
1093
-
1094
- it("handles POST requests for queries", async () => {
1095
- const getUser = query()
1096
- .input(z.object({ id: z.string() }))
1097
- .returns(User)
1098
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1099
-
1100
- const server = createServer({
1101
- entities: { User },
1102
- queries: { getUser },
1103
- });
1104
-
1105
- const request = new Request("http://localhost/api", {
1106
- method: "POST",
1107
- headers: { "Content-Type": "application/json" },
1108
- body: JSON.stringify({ operation: "getUser", input: { id: "user-1" } }),
1109
- });
1110
-
1111
- const response = await server.handleRequest(request);
1112
- expect(response.status).toBe(200);
1113
-
1114
- const body = await response.json();
1115
- expect(body.data).toEqual(mockUsers[0]);
1116
- });
1117
- });
1118
-
1119
- // =============================================================================
1120
- // Test: Context creation errors
1121
- // =============================================================================
1122
-
1123
- describe("Context creation errors", () => {
1124
- it("handles context factory errors in executeQuery", async () => {
1125
- const getUser = query()
1126
- .returns(User)
1127
- .resolve(() => mockUsers[0]);
1128
-
1129
- const server = createServer({
1130
- entities: { User },
1131
- queries: { getUser },
1132
- context: () => {
1133
- throw new Error("Context creation failed");
1134
- },
1135
- });
1136
-
1137
- await expect(server.executeQuery("getUser")).rejects.toThrow("Context creation failed");
1138
- });
1139
-
1140
- it("handles async context factory errors in executeMutation", async () => {
1141
- const createUser = mutation()
1142
- .input(z.object({ name: z.string() }))
1143
- .returns(User)
1144
- .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
1145
-
1146
- const server = createServer({
1147
- entities: { User },
1148
- mutations: { createUser },
1149
- context: async () => {
1150
- throw new Error("Async context error");
1151
- },
1152
- });
1153
-
1154
- await expect(server.executeMutation("createUser", { name: "Test" })).rejects.toThrow("Async context error");
1155
- });
1156
-
1157
- it("handles context errors in subscription", async () => {
1158
- const liveQuery = query()
1159
- .returns(User)
1160
- .resolve(() => mockUsers[0]);
1161
-
1162
- const server = createServer({
1163
- entities: { User },
1164
- queries: { liveQuery },
1165
- context: () => {
1166
- throw new Error("Context error in subscription");
1167
- },
1168
- });
1169
-
1170
- const ws = createMockWs();
1171
- server.handleWebSocket(ws);
1172
-
1173
- ws.onmessage?.({
1174
- data: JSON.stringify({
1175
- type: "subscribe",
1176
- id: "sub-1",
1177
- operation: "liveQuery",
1178
- fields: "*",
1179
- }),
1180
- });
1181
-
1182
- await new Promise((r) => setTimeout(r, 50));
1183
-
1184
- // Should receive error message
1185
- const errorMsg = ws.messages.find((m) => {
1186
- const parsed = JSON.parse(m);
1187
- return parsed.type === "error" && parsed.id === "sub-1";
1188
- });
1189
-
1190
- expect(errorMsg).toBeDefined();
1191
- const parsed = JSON.parse(errorMsg!);
1192
- expect(parsed.error.message).toContain("Context error in subscription");
1193
- });
1194
- });
1195
-
1196
- // =============================================================================
1197
- // Test: Subscription edge cases
1198
- // =============================================================================
1199
-
1200
- describe("Subscription edge cases", () => {
1201
- it("handles subscription input validation errors", async () => {
1202
- const getUser = query()
1203
- .input(z.object({ id: z.string().min(5) }))
1204
- .returns(User)
1205
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1206
-
1207
- const server = createServer({
1208
- entities: { User },
1209
- queries: { getUser },
1210
- });
1211
-
1212
- const ws = createMockWs();
1213
- server.handleWebSocket(ws);
1214
-
1215
- ws.onmessage?.({
1216
- data: JSON.stringify({
1217
- type: "subscribe",
1218
- id: "sub-1",
1219
- operation: "getUser",
1220
- input: { id: "a" }, // Too short
1221
- fields: "*",
1222
- }),
1223
- });
1224
-
1225
- await new Promise((r) => setTimeout(r, 50));
1226
-
1227
- const errorMsg = ws.messages.find((m) => {
1228
- const parsed = JSON.parse(m);
1229
- return parsed.type === "error";
1230
- });
1231
-
1232
- expect(errorMsg).toBeDefined();
1233
- const parsed = JSON.parse(errorMsg!);
1234
- expect(parsed.error.message).toContain("Invalid input");
1235
- });
1236
-
1237
- it("handles updateFields for non-existent subscription", () => {
1238
- const server = createServer({
1239
- entities: { User },
1240
- });
1241
-
1242
- const ws = createMockWs();
1243
- server.handleWebSocket(ws);
1244
-
1245
- // Try to update fields for non-existent subscription
1246
- ws.onmessage?.({
1247
- data: JSON.stringify({
1248
- type: "updateFields",
1249
- id: "non-existent",
1250
- addFields: ["name"],
1251
- }),
1252
- });
1253
-
1254
- // Should not throw - just be a no-op
1255
- expect(true).toBe(true);
1256
- });
1257
-
1258
- it("handles unsubscribe for non-existent subscription", () => {
1259
- const server = createServer({
1260
- entities: { User },
1261
- });
1262
-
1263
- const ws = createMockWs();
1264
- server.handleWebSocket(ws);
1265
-
1266
- // Try to unsubscribe from non-existent subscription
1267
- ws.onmessage?.({
1268
- data: JSON.stringify({
1269
- type: "unsubscribe",
1270
- id: "non-existent",
1271
- }),
1272
- });
1273
-
1274
- // Should not throw - just be a no-op
1275
- expect(true).toBe(true);
1276
- });
1277
-
1278
- it("upgrades to full subscription with wildcard", async () => {
1279
- const getUser = query()
1280
- .input(z.object({ id: z.string() }))
1281
- .returns(User)
1282
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1283
-
1284
- const server = createServer({
1285
- entities: { User },
1286
- queries: { getUser },
1287
- });
1288
-
1289
- const ws = createMockWs();
1290
- server.handleWebSocket(ws);
1291
-
1292
- // Subscribe with partial fields
1293
- ws.onmessage?.({
1294
- data: JSON.stringify({
1295
- type: "subscribe",
1296
- id: "sub-1",
1297
- operation: "getUser",
1298
- input: { id: "user-1" },
1299
- fields: ["name"],
1300
- }),
1301
- });
1302
-
1303
- await new Promise((r) => setTimeout(r, 20));
1304
-
1305
- // Upgrade to full subscription
1306
- ws.onmessage?.({
1307
- data: JSON.stringify({
1308
- type: "updateFields",
1309
- id: "sub-1",
1310
- addFields: ["*"],
1311
- }),
1312
- });
1313
-
1314
- // Should not throw
1315
- expect(true).toBe(true);
1316
- });
1317
-
1318
- it("downgrades from wildcard to specific fields", async () => {
1319
- const getUser = query()
1320
- .input(z.object({ id: z.string() }))
1321
- .returns(User)
1322
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1323
-
1324
- const server = createServer({
1325
- entities: { User },
1326
- queries: { getUser },
1327
- });
1328
-
1329
- const ws = createMockWs();
1330
- server.handleWebSocket(ws);
1331
-
1332
- // Subscribe with wildcard
1333
- ws.onmessage?.({
1334
- data: JSON.stringify({
1335
- type: "subscribe",
1336
- id: "sub-1",
1337
- operation: "getUser",
1338
- input: { id: "user-1" },
1339
- fields: "*",
1340
- }),
1341
- });
1342
-
1343
- await new Promise((r) => setTimeout(r, 20));
1344
-
1345
- // Downgrade to specific fields
1346
- ws.onmessage?.({
1347
- data: JSON.stringify({
1348
- type: "updateFields",
1349
- id: "sub-1",
1350
- setFields: ["name", "email"],
1351
- }),
1352
- });
1353
-
1354
- // Should not throw
1355
- expect(true).toBe(true);
1356
- });
1357
-
1358
- it("ignores add/remove when already subscribed to wildcard", async () => {
1359
- const getUser = query()
1360
- .input(z.object({ id: z.string() }))
1361
- .returns(User)
1362
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1363
-
1364
- const server = createServer({
1365
- entities: { User },
1366
- queries: { getUser },
1367
- });
1368
-
1369
- const ws = createMockWs();
1370
- server.handleWebSocket(ws);
1371
-
1372
- // Subscribe with wildcard
1373
- ws.onmessage?.({
1374
- data: JSON.stringify({
1375
- type: "subscribe",
1376
- id: "sub-1",
1377
- operation: "getUser",
1378
- input: { id: "user-1" },
1379
- fields: "*",
1380
- }),
1381
- });
1382
-
1383
- await new Promise((r) => setTimeout(r, 20));
1384
-
1385
- // Try to add fields (should be ignored)
1386
- ws.onmessage?.({
1387
- data: JSON.stringify({
1388
- type: "updateFields",
1389
- id: "sub-1",
1390
- addFields: ["bio"],
1391
- }),
1392
- });
1393
-
1394
- // Should not throw
1395
- expect(true).toBe(true);
1396
- });
1397
-
1398
- it("handles async generator with empty stream", async () => {
1399
- const emptyStream = query()
1400
- .returns(User)
1401
- .resolve(async function* () {
1402
- // Empty generator - yields nothing
1403
- });
1404
-
1405
- const server = createServer({
1406
- entities: { User },
1407
- queries: { emptyStream },
1408
- });
1409
-
1410
- await expect(server.executeQuery("emptyStream")).rejects.toThrow("returned empty stream");
1411
- });
1412
- });
1413
-
1414
- // =============================================================================
1415
- // Test: Query with $select
1416
- // =============================================================================
1417
-
1418
- describe("Query with $select", () => {
1419
- it("handles query with $select parameter", async () => {
1420
- const getUser = query()
1421
- .input(z.object({ id: z.string() }))
1422
- .returns(User)
1423
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1424
-
1425
- const server = createServer({
1426
- entities: { User },
1427
- queries: { getUser },
1428
- });
1429
-
1430
- // Use $select to trigger selection processing
1431
- const result = await server.executeQuery("getUser", {
1432
- id: "user-1",
1433
- $select: { name: true, email: true },
1434
- });
1435
-
1436
- expect(result).toBeDefined();
1437
- expect((result as any).id).toBe("user-1");
1438
- expect((result as any).name).toBe("Alice");
1439
- });
1440
-
1441
- it("processes WebSocket query message with select", async () => {
1442
- const getUser = query()
1443
- .input(z.object({ id: z.string() }))
1444
- .returns(User)
1445
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1446
-
1447
- const server = createServer({
1448
- entities: { User },
1449
- queries: { getUser },
1450
- });
1451
-
1452
- const ws = createMockWs();
1453
- server.handleWebSocket(ws);
1454
-
1455
- // Query with select
1456
- ws.onmessage?.({
1457
- data: JSON.stringify({
1458
- type: "query",
1459
- id: "q-1",
1460
- operation: "getUser",
1461
- input: { id: "user-1" },
1462
- select: { name: true, email: true },
1463
- }),
1464
- });
1465
-
1466
- await new Promise((r) => setTimeout(r, 20));
1467
-
1468
- expect(ws.messages.length).toBe(1);
1469
- const response = JSON.parse(ws.messages[0]);
1470
- expect(response.type).toBe("result");
1471
- expect(response.data.name).toBe("Alice");
1472
- });
1473
-
1474
- it("applies field selection with fields array", async () => {
1475
- const getUser = query()
1476
- .input(z.object({ id: z.string() }))
1477
- .returns(User)
1478
- .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1479
-
1480
- const server = createServer({
1481
- entities: { User },
1482
- queries: { getUser },
1483
- });
1484
-
1485
- const ws = createMockWs();
1486
- server.handleWebSocket(ws);
1487
-
1488
- // Query with fields array (backward compat)
1489
- ws.onmessage?.({
1490
- data: JSON.stringify({
1491
- type: "query",
1492
- id: "q-1",
1493
- operation: "getUser",
1494
- input: { id: "user-1" },
1495
- fields: ["name"],
1496
- }),
1497
- });
1498
-
1499
- await new Promise((r) => setTimeout(r, 20));
1500
-
1501
- expect(ws.messages.length).toBe(1);
1502
- const response = JSON.parse(ws.messages[0]);
1503
- expect(response.type).toBe("result");
1504
- expect(response.data.id).toBe("user-1"); // id is always included
1505
- expect(response.data.name).toBe("Alice");
1506
- });
1507
- });
1508
-
1509
- // =============================================================================
1510
- // Test: Logger integration
1511
- // =============================================================================
1512
-
1513
- describe("Logger integration", () => {
1514
- it("calls logger.error on cleanup errors", async () => {
1515
- const errorLogs: string[] = [];
1516
- const liveQuery = query()
1517
- .returns(User)
1518
- .resolve(({ ctx }) => {
1519
- ctx.onCleanup(() => {
1520
- throw new Error("Cleanup failed");
1521
- });
1522
- return mockUsers[0];
1523
- });
1524
-
1525
- const server = createServer({
1526
- entities: { User },
1527
- queries: { liveQuery },
1528
- logger: {
1529
- error: (msg, ...args) => {
1530
- errorLogs.push(`${msg} ${args.join(" ")}`);
1531
- },
1532
- },
1533
- });
1534
-
1535
- const ws = createMockWs();
1536
- server.handleWebSocket(ws);
1537
-
1538
- // Subscribe
1539
- ws.onmessage?.({
1540
- data: JSON.stringify({
1541
- type: "subscribe",
1542
- id: "sub-1",
1543
- operation: "liveQuery",
1544
- fields: "*",
1545
- }),
1546
- });
1547
-
1548
- await new Promise((r) => setTimeout(r, 20));
1549
-
1550
- // Unsubscribe (triggers cleanup error)
1551
- ws.onmessage?.({
1552
- data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
1553
- });
1554
-
1555
- expect(errorLogs.length).toBeGreaterThan(0);
1556
- expect(errorLogs[0]).toContain("Cleanup error");
1557
- });
1558
-
1559
- it("calls logger.error on disconnect cleanup errors", async () => {
1560
- const errorLogs: string[] = [];
1561
- const liveQuery = query()
1562
- .returns(User)
1563
- .resolve(({ ctx }) => {
1564
- ctx.onCleanup(() => {
1565
- throw new Error("Disconnect cleanup failed");
1566
- });
1567
- return mockUsers[0];
1568
- });
1569
-
1570
- const server = createServer({
1571
- entities: { User },
1572
- queries: { liveQuery },
1573
- logger: {
1574
- error: (msg, ...args) => {
1575
- errorLogs.push(`${msg} ${args.join(" ")}`);
1576
- },
1577
- },
1578
- });
1579
-
1580
- const ws = createMockWs();
1581
- server.handleWebSocket(ws);
1582
-
1583
- // Subscribe
1584
- ws.onmessage?.({
1585
- data: JSON.stringify({
1586
- type: "subscribe",
1587
- id: "sub-1",
1588
- operation: "liveQuery",
1589
- fields: "*",
1590
- }),
1591
- });
1592
-
1593
- await new Promise((r) => setTimeout(r, 20));
1594
-
1595
- // Disconnect (triggers cleanup)
1596
- ws.onclose?.();
1597
-
1598
- expect(errorLogs.length).toBeGreaterThan(0);
1599
- expect(errorLogs[0]).toContain("Cleanup error");
1600
- });
1601
- });
1602
-
1603
- // =============================================================================
1604
- // Test: DataLoader Batching
1605
- // =============================================================================
1606
-
1607
- describe("DataLoader Batching", () => {
1608
- it("batches multiple load calls into single batch function call", async () => {
1609
- let batchCallCount = 0;
1610
- let receivedKeys: string[] = [];
1611
-
1612
- class TestDataLoader {
1613
- private batch: Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }[]> = new Map();
1614
- private scheduled = false;
1615
-
1616
- constructor(private batchFn: (keys: string[]) => Promise<(string | null)[]>) {}
1617
-
1618
- async load(key: string): Promise<string | null> {
1619
- return new Promise((resolve, reject) => {
1620
- const existing = this.batch.get(key);
1621
- if (existing) {
1622
- existing.push({ resolve, reject });
1623
- } else {
1624
- this.batch.set(key, [{ resolve, reject }]);
1625
- }
1626
- this.scheduleDispatch();
1627
- });
1628
- }
1629
-
1630
- private scheduleDispatch(): void {
1631
- if (this.scheduled) return;
1632
- this.scheduled = true;
1633
- queueMicrotask(() => this.dispatch());
1634
- }
1635
-
1636
- private async dispatch(): Promise<void> {
1637
- this.scheduled = false;
1638
- const batch = this.batch;
1639
- this.batch = new Map();
1640
-
1641
- const keys = Array.from(batch.keys());
1642
- if (keys.length === 0) return;
1643
-
1644
- try {
1645
- const results = await this.batchFn(keys);
1646
- keys.forEach((key, index) => {
1647
- const callbacks = batch.get(key)!;
1648
- const result = results[index] ?? null;
1649
- for (const { resolve } of callbacks) resolve(result);
1650
- });
1651
- } catch (error) {
1652
- for (const callbacks of batch.values()) {
1653
- for (const { reject } of callbacks) reject(error as Error);
1654
- }
1655
- }
1656
- }
1657
-
1658
- clear(): void {
1659
- this.batch.clear();
1660
- }
1661
- }
1662
-
1663
- const loader = new TestDataLoader(async (keys) => {
1664
- batchCallCount++;
1665
- receivedKeys = keys;
1666
- return keys.map((k) => `value-${k}`);
1667
- });
1668
-
1669
- // Load multiple keys in same tick
1670
- const promises = [loader.load("key1"), loader.load("key2"), loader.load("key3")];
1671
-
1672
- const results = await Promise.all(promises);
1673
-
1674
- // Should batch all calls into single batch function call
1675
- expect(batchCallCount).toBe(1);
1676
- expect(receivedKeys).toEqual(["key1", "key2", "key3"]);
1677
- expect(results).toEqual(["value-key1", "value-key2", "value-key3"]);
1678
- });
1679
-
1680
- it("handles duplicate keys in same batch", async () => {
1681
- class TestDataLoader {
1682
- private batch: Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }[]> = new Map();
1683
- private scheduled = false;
1684
-
1685
- constructor(private batchFn: (keys: string[]) => Promise<(string | null)[]>) {}
1686
-
1687
- async load(key: string): Promise<string | null> {
1688
- return new Promise((resolve, reject) => {
1689
- const existing = this.batch.get(key);
1690
- if (existing) {
1691
- existing.push({ resolve, reject });
1692
- } else {
1693
- this.batch.set(key, [{ resolve, reject }]);
1694
- }
1695
- this.scheduleDispatch();
1696
- });
1697
- }
1698
-
1699
- private scheduleDispatch(): void {
1700
- if (this.scheduled) return;
1701
- this.scheduled = true;
1702
- queueMicrotask(() => this.dispatch());
1703
- }
1704
-
1705
- private async dispatch(): Promise<void> {
1706
- this.scheduled = false;
1707
- const batch = this.batch;
1708
- this.batch = new Map();
1709
-
1710
- const keys = Array.from(batch.keys());
1711
- if (keys.length === 0) return;
1712
-
1713
- try {
1714
- const results = await this.batchFn(keys);
1715
- keys.forEach((key, index) => {
1716
- const callbacks = batch.get(key)!;
1717
- const result = results[index] ?? null;
1718
- for (const { resolve } of callbacks) resolve(result);
1719
- });
1720
- } catch (error) {
1721
- for (const callbacks of batch.values()) {
1722
- for (const { reject } of callbacks) reject(error as Error);
1723
- }
1724
- }
1725
- }
1726
-
1727
- clear(): void {
1728
- this.batch.clear();
1729
- }
1730
- }
1731
-
1732
- const loader = new TestDataLoader(async (keys) => {
1733
- return keys.map((k) => `value-${k}`);
1734
- });
1735
-
1736
- // Load same key multiple times
1737
- const promises = [loader.load("key1"), loader.load("key1"), loader.load("key1")];
1738
-
1739
- const results = await Promise.all(promises);
1740
-
1741
- // All should resolve with same value
1742
- expect(results).toEqual(["value-key1", "value-key1", "value-key1"]);
1743
- });
1744
-
1745
- it("handles batch function errors", async () => {
1746
- class TestDataLoader {
1747
- private batch: Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }[]> = new Map();
1748
- private scheduled = false;
1749
-
1750
- constructor(private batchFn: (keys: string[]) => Promise<(string | null)[]>) {}
1751
-
1752
- async load(key: string): Promise<string | null> {
1753
- return new Promise((resolve, reject) => {
1754
- const existing = this.batch.get(key);
1755
- if (existing) {
1756
- existing.push({ resolve, reject });
1757
- } else {
1758
- this.batch.set(key, [{ resolve, reject }]);
1759
- }
1760
- this.scheduleDispatch();
1761
- });
1762
- }
1763
-
1764
- private scheduleDispatch(): void {
1765
- if (this.scheduled) return;
1766
- this.scheduled = true;
1767
- queueMicrotask(() => this.dispatch());
1768
- }
1769
-
1770
- private async dispatch(): Promise<void> {
1771
- this.scheduled = false;
1772
- const batch = this.batch;
1773
- this.batch = new Map();
1774
-
1775
- const keys = Array.from(batch.keys());
1776
- if (keys.length === 0) return;
1777
-
1778
- try {
1779
- const results = await this.batchFn(keys);
1780
- keys.forEach((key, index) => {
1781
- const callbacks = batch.get(key)!;
1782
- const result = results[index] ?? null;
1783
- for (const { resolve } of callbacks) resolve(result);
1784
- });
1785
- } catch (error) {
1786
- for (const callbacks of batch.values()) {
1787
- for (const { reject } of callbacks) reject(error as Error);
1788
- }
1789
- }
1790
- }
1791
-
1792
- clear(): void {
1793
- this.batch.clear();
1794
- }
1795
- }
1796
-
1797
- const loader = new TestDataLoader(async () => {
1798
- throw new Error("Batch function error");
1799
- });
1800
-
1801
- const promises = [loader.load("key1"), loader.load("key2")];
1802
-
1803
- // All loads should reject with same error
1804
- await expect(Promise.all(promises)).rejects.toThrow("Batch function error");
1805
- });
1806
-
1807
- it("does not schedule dispatch twice if already scheduled", async () => {
1808
- let dispatchCount = 0;
1809
-
1810
- class TestDataLoader {
1811
- private batch: Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }[]> = new Map();
1812
- private scheduled = false;
1813
-
1814
- constructor(private batchFn: (keys: string[]) => Promise<(string | null)[]>) {}
1815
-
1816
- async load(key: string): Promise<string | null> {
1817
- return new Promise((resolve, reject) => {
1818
- const existing = this.batch.get(key);
1819
- if (existing) {
1820
- existing.push({ resolve, reject });
1821
- } else {
1822
- this.batch.set(key, [{ resolve, reject }]);
1823
- }
1824
- this.scheduleDispatch();
1825
- });
1826
- }
1827
-
1828
- private scheduleDispatch(): void {
1829
- if (this.scheduled) return;
1830
- this.scheduled = true;
1831
- queueMicrotask(() => this.dispatch());
1832
- }
1833
-
1834
- private async dispatch(): Promise<void> {
1835
- dispatchCount++;
1836
- this.scheduled = false;
1837
- const batch = this.batch;
1838
- this.batch = new Map();
1839
-
1840
- const keys = Array.from(batch.keys());
1841
- if (keys.length === 0) return;
1842
-
1843
- try {
1844
- const results = await this.batchFn(keys);
1845
- keys.forEach((key, index) => {
1846
- const callbacks = batch.get(key)!;
1847
- const result = results[index] ?? null;
1848
- for (const { resolve } of callbacks) resolve(result);
1849
- });
1850
- } catch (error) {
1851
- for (const callbacks of batch.values()) {
1852
- for (const { reject } of callbacks) reject(error as Error);
1853
- }
1854
- }
1855
- }
1856
-
1857
- clear(): void {
1858
- this.batch.clear();
1859
- }
1860
- }
1861
-
1862
- const loader = new TestDataLoader(async (keys) => {
1863
- return keys.map((k) => `value-${k}`);
1864
- });
1865
-
1866
- // Load multiple keys
1867
- await Promise.all([loader.load("key1"), loader.load("key2"), loader.load("key3")]);
1868
-
1869
- // Should only dispatch once despite multiple load calls
1870
- expect(dispatchCount).toBe(1);
1871
- });
1872
-
1873
- it("clears pending batches when clear is called", () => {
1874
- class TestDataLoader {
1875
- private batch: Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }[]> = new Map();
1876
- private scheduled = false;
1877
-
1878
- constructor(private batchFn: (keys: string[]) => Promise<(string | null)[]>) {}
1879
-
1880
- async load(key: string): Promise<string | null> {
1881
- return new Promise((resolve, reject) => {
1882
- const existing = this.batch.get(key);
1883
- if (existing) {
1884
- existing.push({ resolve, reject });
1885
- } else {
1886
- this.batch.set(key, [{ resolve, reject }]);
1887
- }
1888
- this.scheduleDispatch();
1889
- });
1890
- }
1891
-
1892
- private scheduleDispatch(): void {
1893
- if (this.scheduled) return;
1894
- this.scheduled = true;
1895
- queueMicrotask(() => this.dispatch());
1896
- }
1897
-
1898
- private async dispatch(): Promise<void> {
1899
- this.scheduled = false;
1900
- const batch = this.batch;
1901
- this.batch = new Map();
1902
-
1903
- const keys = Array.from(batch.keys());
1904
- if (keys.length === 0) return;
1905
-
1906
- try {
1907
- const results = await this.batchFn(keys);
1908
- keys.forEach((key, index) => {
1909
- const callbacks = batch.get(key)!;
1910
- const result = results[index] ?? null;
1911
- for (const { resolve } of callbacks) resolve(result);
1912
- });
1913
- } catch (error) {
1914
- for (const callbacks of batch.values()) {
1915
- for (const { reject } of callbacks) reject(error as Error);
1916
- }
1917
- }
1918
- }
1919
-
1920
- clear(): void {
1921
- this.batch.clear();
1922
- }
1923
-
1924
- getBatchSize(): number {
1925
- return this.batch.size;
1926
- }
1927
- }
1928
-
1929
- const loader = new TestDataLoader(async (keys) => {
1930
- return keys.map((k) => `value-${k}`);
1931
- });
1932
-
1933
- // Add some items to batch (but don't await - they won't dispatch yet)
1934
- loader.load("key1");
1935
- loader.load("key2");
1936
-
1937
- // Clear should remove pending items
1938
- loader.clear();
1939
-
1940
- // Batch should be empty
1941
- expect(loader.getBatchSize()).toBe(0);
1942
- });
1943
-
1944
- it("handles null results from batch function", async () => {
1945
- class TestDataLoader {
1946
- private batch: Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }[]> = new Map();
1947
- private scheduled = false;
1948
-
1949
- constructor(private batchFn: (keys: string[]) => Promise<(string | null)[]>) {}
1950
-
1951
- async load(key: string): Promise<string | null> {
1952
- return new Promise((resolve, reject) => {
1953
- const existing = this.batch.get(key);
1954
- if (existing) {
1955
- existing.push({ resolve, reject });
1956
- } else {
1957
- this.batch.set(key, [{ resolve, reject }]);
1958
- }
1959
- this.scheduleDispatch();
1960
- });
1961
- }
1962
-
1963
- private scheduleDispatch(): void {
1964
- if (this.scheduled) return;
1965
- this.scheduled = true;
1966
- queueMicrotask(() => this.dispatch());
1967
- }
1968
-
1969
- private async dispatch(): Promise<void> {
1970
- this.scheduled = false;
1971
- const batch = this.batch;
1972
- this.batch = new Map();
1973
-
1974
- const keys = Array.from(batch.keys());
1975
- if (keys.length === 0) return;
1976
-
1977
- try {
1978
- const results = await this.batchFn(keys);
1979
- keys.forEach((key, index) => {
1980
- const callbacks = batch.get(key)!;
1981
- const result = results[index] ?? null;
1982
- for (const { resolve } of callbacks) resolve(result);
1983
- });
1984
- } catch (error) {
1985
- for (const callbacks of batch.values()) {
1986
- for (const { reject } of callbacks) reject(error as Error);
1987
- }
1988
- }
1989
- }
1990
-
1991
- clear(): void {
1992
- this.batch.clear();
1993
- }
1994
- }
1995
-
1996
- const loader = new TestDataLoader(async (keys) => {
1997
- // Return null for some keys
1998
- return keys.map((k) => (k === "key2" ? null : `value-${k}`));
1999
- });
2000
-
2001
- const results = await Promise.all([loader.load("key1"), loader.load("key2"), loader.load("key3")]);
2002
-
2003
- expect(results).toEqual(["value-key1", null, "value-key3"]);
2004
- });
2005
- });
2006
-
2007
- // =============================================================================
2008
- // Test: HTTP Server Lifecycle (listen, close, findConnectionByWs)
2009
- // =============================================================================
2010
-
2011
- describe("HTTP Server Lifecycle", () => {
2012
- it("handles GET requests that are not metadata endpoint", async () => {
2013
- const server = createServer({
2014
- entities: { User },
2015
- });
2016
-
2017
- const request = new Request("http://localhost/some-other-path", { method: "GET" });
2018
- const response = await server.handleRequest(request);
2019
-
2020
- expect(response.status).toBe(405);
2021
- const text = await response.text();
2022
- expect(text).toBe("Method not allowed");
2023
- });
2024
-
2025
- it("handles PUT requests", async () => {
2026
- const server = createServer({
2027
- entities: { User },
2028
- });
2029
-
2030
- const request = new Request("http://localhost/api", { method: "PUT" });
2031
- const response = await server.handleRequest(request);
2032
-
2033
- expect(response.status).toBe(405);
2034
- const text = await response.text();
2035
- expect(text).toBe("Method not allowed");
2036
- });
2037
-
2038
- it("handles DELETE requests", async () => {
2039
- const server = createServer({
2040
- entities: { User },
2041
- });
2042
-
2043
- const request = new Request("http://localhost/api", { method: "DELETE" });
2044
- const response = await server.handleRequest(request);
2045
-
2046
- expect(response.status).toBe(405);
2047
- const text = await response.text();
2048
- expect(text).toBe("Method not allowed");
2049
- });
2050
-
2051
- it("handles PATCH requests", async () => {
2052
- const server = createServer({
2053
- entities: { User },
2054
- });
2055
-
2056
- const request = new Request("http://localhost/api", { method: "PATCH" });
2057
- const response = await server.handleRequest(request);
2058
-
2059
- expect(response.status).toBe(405);
2060
- const text = await response.text();
2061
- expect(text).toBe("Method not allowed");
2062
- });
2063
-
2064
- it("handles OPTIONS requests", async () => {
2065
- const server = createServer({
2066
- entities: { User },
2067
- });
2068
-
2069
- const request = new Request("http://localhost/api", { method: "OPTIONS" });
2070
- const response = await server.handleRequest(request);
2071
-
2072
- expect(response.status).toBe(405);
2073
- const text = await response.text();
2074
- expect(text).toBe("Method not allowed");
2075
- });
2076
-
2077
- it("handles HEAD requests", async () => {
2078
- const server = createServer({
2079
- entities: { User },
2080
- });
2081
-
2082
- const request = new Request("http://localhost/api", { method: "HEAD" });
2083
- const response = await server.handleRequest(request);
2084
-
2085
- expect(response.status).toBe(405);
2086
- });
2087
-
2088
- it("can start and stop server with listen/close", async () => {
2089
- const server = createServer({
2090
- entities: { User },
2091
- logger: {
2092
- info: () => {}, // Silent logger for test
2093
- },
2094
- });
2095
-
2096
- // Start server on a random high port to avoid conflicts
2097
- const port = 30000 + Math.floor(Math.random() * 10000);
2098
-
2099
- try {
2100
- await server.listen(port);
2101
-
2102
- // Verify server is running by making a request
2103
- const response = await fetch(`http://localhost:${port}/__lens/metadata`);
2104
- expect(response.status).toBe(200);
2105
- const data = await response.json();
2106
- expect(data.version).toBeDefined();
2107
- } finally {
2108
- // Always close the server
2109
- await server.close();
2110
- }
2111
- });
2112
-
2113
- it("handles method not allowed via real HTTP server", async () => {
2114
- const server = createServer({
2115
- entities: { User },
2116
- logger: {
2117
- info: () => {}, // Silent logger for test
2118
- },
2119
- });
2120
-
2121
- const port = 30000 + Math.floor(Math.random() * 10000);
2122
-
2123
- try {
2124
- await server.listen(port);
2125
-
2126
- // Make a PUT request which should return 405
2127
- const response = await fetch(`http://localhost:${port}/api`, { method: "PUT" });
2128
- expect(response.status).toBe(405);
2129
- const text = await response.text();
2130
- expect(text).toBe("Method not allowed");
2131
- } finally {
2132
- await server.close();
2133
- }
2134
- });
2135
-
2136
- // Note: WebSocket integration via Bun.serve's native WebSocket upgrade (lines 1184-1193)
2137
- // is tested through unit tests using mock WebSockets. Full integration tests with real
2138
- // WebSocket clients would require additional setup and are better suited for E2E tests.
2139
- });
2140
-
2141
- // =============================================================================
2142
- // Test: SSE Handler Edge Cases
2143
- // =============================================================================
2144
-
2145
- describe("SSE Handler Edge Cases", () => {
2146
- it("handles WebSocket error callback", async () => {
2147
- const server = createServer({
2148
- entities: { User },
2149
- });
2150
-
2151
- const ws = createMockWs();
2152
- server.handleWebSocket(ws);
2153
-
2154
- // Trigger error callback (if set)
2155
- if (ws.onerror) {
2156
- ws.onerror(new Error("WebSocket error"));
2157
- }
2158
-
2159
- // Should not crash
2160
- expect(true).toBe(true);
2161
- });
2162
- });
2163
-
2164
- // =============================================================================
2165
- // Test: Entity Resolvers
2166
- // =============================================================================
2167
-
2168
- describe("Entity Resolvers", () => {
2169
- it("executes field resolvers with select", async () => {
2170
- const Author = entity("Author", {
2171
- id: t.id(),
2172
- name: t.string(),
2173
- });
2174
-
2175
- const Article = entity("Article", {
2176
- id: t.id(),
2177
- title: t.string(),
2178
- authorId: t.string(),
2179
- // author relation is resolved
2180
- });
2181
-
2182
- const mockAuthors = [
2183
- { id: "author-1", name: "Alice" },
2184
- { id: "author-2", name: "Bob" },
2185
- ];
2186
-
2187
- const mockArticles = [
2188
- { id: "article-1", title: "First Post", authorId: "author-1" },
2189
- { id: "article-2", title: "Second Post", authorId: "author-2" },
2190
- ];
2191
-
2192
- // Create resolver for Article entity
2193
- const articleResolver = resolver(Article, (f) => ({
2194
- id: f.expose("id"),
2195
- title: f.expose("title"),
2196
- author: f.one(Author).resolve(({ parent }) => {
2197
- return mockAuthors.find((a) => a.id === parent.authorId) ?? null;
2198
- }),
2199
- }));
2200
-
2201
- const getArticle = query()
2202
- .input(z.object({ id: z.string() }))
2203
- .returns(Article)
2204
- .resolve(({ input }) => {
2205
- return mockArticles.find((a) => a.id === input.id) ?? null;
2206
- });
2207
-
2208
- const server = createServer({
2209
- entities: { Article, Author },
2210
- queries: { getArticle },
2211
- resolvers: [articleResolver],
2212
- });
2213
-
2214
- const result = await server.executeQuery("getArticle", {
2215
- id: "article-1",
2216
- $select: {
2217
- title: true,
2218
- author: {
2219
- select: {
2220
- name: true,
2221
- },
2222
- },
2223
- },
2224
- });
2225
-
2226
- expect(result).toBeDefined();
2227
- expect((result as any).title).toBe("First Post");
2228
- expect((result as any).author).toBeDefined();
2229
- expect((result as any).author.name).toBe("Alice");
2230
- });
2231
-
2232
- it("handles array relations in resolvers", async () => {
2233
- const Author = entity("Author", {
2234
- id: t.id(),
2235
- name: t.string(),
2236
- });
2237
-
2238
- const Article = entity("Article", {
2239
- id: t.id(),
2240
- title: t.string(),
2241
- authorId: t.string(),
2242
- });
2243
-
2244
- const mockArticles = [
2245
- { id: "article-1", title: "First Post", authorId: "author-1" },
2246
- { id: "article-2", title: "Second Post", authorId: "author-1" },
2247
- { id: "article-3", title: "Third Post", authorId: "author-2" },
2248
- ];
2249
-
2250
- // Create resolver for Author entity with articles relation
2251
- const authorResolver = resolver(Author, (f) => ({
2252
- id: f.expose("id"),
2253
- name: f.expose("name"),
2254
- articles: f.many(Article).resolve(({ parent }) => {
2255
- return mockArticles.filter((a) => a.authorId === parent.id);
2256
- }),
2257
- }));
2258
-
2259
- const getAuthor = query()
2260
- .input(z.object({ id: z.string() }))
2261
- .returns(Author)
2262
- .resolve(({ input }) => {
2263
- return { id: input.id, name: input.id === "author-1" ? "Alice" : "Bob" };
2264
- });
2265
-
2266
- const server = createServer({
2267
- entities: { Article, Author },
2268
- queries: { getAuthor },
2269
- resolvers: [authorResolver],
2270
- });
2271
-
2272
- const result = await server.executeQuery("getAuthor", {
2273
- id: "author-1",
2274
- $select: {
2275
- name: true,
2276
- articles: {
2277
- select: {
2278
- title: true,
2279
- },
2280
- },
2281
- },
2282
- });
2283
-
2284
- expect(result).toBeDefined();
2285
- expect((result as any).name).toBe("Alice");
2286
- expect((result as any).articles).toBeDefined();
2287
- expect(Array.isArray((result as any).articles)).toBe(true);
2288
- expect((result as any).articles.length).toBe(2);
2289
- expect((result as any).articles[0].title).toBe("First Post");
2290
- });
2291
-
2292
- it("handles field resolver with args", async () => {
2293
- const User = entity("User", {
2294
- id: t.id(),
2295
- name: t.string(),
2296
- });
2297
-
2298
- const userResolver = resolver(User, (f) => ({
2299
- id: f.expose("id"),
2300
- name: f.expose("name"),
2301
- greeting: f
2302
- .string()
2303
- .args<{ formal: boolean }>()
2304
- .resolve(({ parent, args }) => {
2305
- return args.formal ? `Good day, ${parent.name}` : `Hey ${parent.name}!`;
2306
- }),
2307
- }));
2308
-
2309
- const getUser = query()
2310
- .returns(User)
2311
- .resolve(() => ({ id: "1", name: "Alice" }));
2312
-
2313
- const server = createServer({
2314
- entities: { User },
2315
- queries: { getUser },
2316
- resolvers: [userResolver],
2317
- });
2318
-
2319
- const result = await server.executeQuery("getUser", {
2320
- $select: {
2321
- name: true,
2322
- greeting: {
2323
- args: { formal: true },
2324
- },
2325
- },
2326
- });
2327
-
2328
- expect(result).toBeDefined();
2329
- expect((result as any).greeting).toBe("Good day, Alice");
2330
- });
2331
-
2332
- it("returns data unchanged when no select provided", async () => {
2333
- const User = entity("User", {
2334
- id: t.id(),
2335
- name: t.string(),
2336
- });
2337
-
2338
- const userResolver = resolver(User, (f) => ({
2339
- id: f.expose("id"),
2340
- name: f.expose("name"),
2341
- bio: f.string().resolve(({ parent }) => `Biography of ${parent.name}`),
2342
- }));
2343
-
2344
- const getUser = query()
2345
- .returns(User)
2346
- .resolve(() => ({ id: "1", name: "Alice" }));
2347
-
2348
- const server = createServer({
2349
- entities: { User },
2350
- queries: { getUser },
2351
- resolvers: [userResolver],
2352
- });
2353
-
2354
- const result = await server.executeQuery("getUser");
419
+ expect(metadata.version).toBeDefined();
2355
420
 
2356
- expect(result).toBeDefined();
2357
- expect((result as any).name).toBe("Alice");
2358
- // bio should not be resolved without select
2359
- expect((result as any).bio).toBeUndefined();
421
+ // The _types property exists for type inference
422
+ expect((server as { _types?: unknown })._types).toBeUndefined(); // Runtime undefined, compile-time exists
2360
423
  });
2361
424
  });