@sylphx/lens-server 1.0.2

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,666 @@
1
+ /**
2
+ * @lens - E2E Tests
3
+ *
4
+ * End-to-end tests for server and client working together.
5
+ * Tests the complete flow of:
6
+ * - Operations protocol (queries, mutations, subscriptions)
7
+ * - GraphStateManager integration
8
+ * - Field-level subscriptions
9
+ * - Minimum transfer (diff computation)
10
+ * - Reference counting and canDerive
11
+ */
12
+
13
+ import { describe, expect, it } from "bun:test";
14
+ import { type Update, applyUpdate, entity, mutation, query, t } from "@sylphx/lens-core";
15
+ import { z } from "zod";
16
+ import { type WebSocketLike, createServer } from "../server/create";
17
+
18
+ // =============================================================================
19
+ // Test Fixtures
20
+ // =============================================================================
21
+
22
+ // Entities
23
+ const User = entity("User", {
24
+ id: t.id(),
25
+ name: t.string(),
26
+ email: t.string(),
27
+ status: t.string(),
28
+ });
29
+
30
+ const Post = entity("Post", {
31
+ id: t.id(),
32
+ title: t.string(),
33
+ content: t.string(),
34
+ authorId: t.string(),
35
+ });
36
+
37
+ // Mock data
38
+ const mockUsers = [
39
+ { id: "user-1", name: "Alice", email: "alice@example.com", status: "online" },
40
+ { id: "user-2", name: "Bob", email: "bob@example.com", status: "offline" },
41
+ ];
42
+
43
+ const mockPosts = [
44
+ { id: "post-1", title: "Hello", content: "World", authorId: "user-1" },
45
+ { id: "post-2", title: "Test", content: "Post", authorId: "user-1" },
46
+ ];
47
+
48
+ // =============================================================================
49
+ // Mock WebSocket Client
50
+ // =============================================================================
51
+
52
+ /**
53
+ * Mock WebSocket client for testing the server.
54
+ * Simulates client-side message handling.
55
+ */
56
+ function createMockClient(server: ReturnType<typeof createServer>) {
57
+ const messages: unknown[] = [];
58
+ const subscriptions = new Map<
59
+ string,
60
+ {
61
+ onData: (data: unknown) => void;
62
+ onUpdate: (updates: Record<string, Update>) => void;
63
+ onError: (error: Error) => void;
64
+ onComplete: () => void;
65
+ lastData: unknown;
66
+ }
67
+ >();
68
+ const pending = new Map<
69
+ string,
70
+ { resolve: (data: unknown) => void; reject: (error: Error) => void }
71
+ >();
72
+
73
+ let messageIdCounter = 0;
74
+ const nextId = () => `msg_${++messageIdCounter}`;
75
+
76
+ // Mock WebSocket interface for server
77
+ const ws: WebSocketLike & { messages: unknown[] } = {
78
+ messages,
79
+ send: (data: string) => {
80
+ const msg = JSON.parse(data);
81
+ messages.push(msg);
82
+
83
+ // Route message to appropriate handler
84
+ if (msg.type === "data" || msg.type === "result") {
85
+ // Response to pending request or subscription initial data
86
+ const pendingReq = pending.get(msg.id);
87
+ if (pendingReq) {
88
+ pending.delete(msg.id);
89
+ pendingReq.resolve(msg.data);
90
+ }
91
+
92
+ const sub = subscriptions.get(msg.id);
93
+ if (sub) {
94
+ sub.lastData = msg.data;
95
+ sub.onData(msg.data);
96
+ }
97
+ } else if (msg.type === "update") {
98
+ const sub = subscriptions.get(msg.id);
99
+ if (sub) {
100
+ // Apply updates to last data
101
+ if (sub.lastData && typeof sub.lastData === "object" && msg.updates) {
102
+ const updated = { ...(sub.lastData as Record<string, unknown>) };
103
+ for (const [field, update] of Object.entries(msg.updates as Record<string, Update>)) {
104
+ updated[field] = applyUpdate(updated[field], update);
105
+ }
106
+ sub.lastData = updated;
107
+ sub.onData(updated);
108
+ }
109
+ sub.onUpdate(msg.updates);
110
+ }
111
+ } else if (msg.type === "error") {
112
+ const pendingReq = pending.get(msg.id);
113
+ if (pendingReq) {
114
+ pending.delete(msg.id);
115
+ pendingReq.reject(new Error(msg.error.message));
116
+ }
117
+
118
+ const sub = subscriptions.get(msg.id);
119
+ if (sub) {
120
+ sub.onError(new Error(msg.error.message));
121
+ }
122
+ }
123
+ },
124
+ close: () => {},
125
+ onmessage: null,
126
+ onclose: null,
127
+ onerror: null,
128
+ };
129
+
130
+ // Connect server to mock WebSocket
131
+ server.handleWebSocket(ws);
132
+
133
+ return {
134
+ ws,
135
+ messages,
136
+
137
+ subscribe(
138
+ operation: string,
139
+ input: unknown,
140
+ fields: string[] | "*",
141
+ callbacks: {
142
+ onData: (data: unknown) => void;
143
+ onUpdate: (updates: Record<string, Update>) => void;
144
+ onError: (error: Error) => void;
145
+ onComplete: () => void;
146
+ },
147
+ ) {
148
+ const id = nextId();
149
+ subscriptions.set(id, { ...callbacks, lastData: null });
150
+
151
+ // Send subscribe message to server
152
+ ws.onmessage?.({
153
+ data: JSON.stringify({
154
+ type: "subscribe",
155
+ id,
156
+ operation,
157
+ input,
158
+ fields,
159
+ }),
160
+ });
161
+
162
+ return {
163
+ unsubscribe: () => {
164
+ subscriptions.delete(id);
165
+ ws.onmessage?.({ data: JSON.stringify({ type: "unsubscribe", id }) });
166
+ callbacks.onComplete();
167
+ },
168
+ updateFields: (add?: string[], remove?: string[]) => {
169
+ ws.onmessage?.({
170
+ data: JSON.stringify({
171
+ type: "updateFields",
172
+ id,
173
+ addFields: add,
174
+ removeFields: remove,
175
+ }),
176
+ });
177
+ },
178
+ };
179
+ },
180
+
181
+ async query(operation: string, input?: unknown, fields?: string[] | "*"): Promise<unknown> {
182
+ return new Promise((resolve, reject) => {
183
+ const id = nextId();
184
+ pending.set(id, { resolve, reject });
185
+
186
+ ws.onmessage?.({
187
+ data: JSON.stringify({
188
+ type: "query",
189
+ id,
190
+ operation,
191
+ input,
192
+ fields,
193
+ }),
194
+ });
195
+ });
196
+ },
197
+
198
+ async mutate(operation: string, input: unknown): Promise<unknown> {
199
+ return new Promise((resolve, reject) => {
200
+ const id = nextId();
201
+ pending.set(id, { resolve, reject });
202
+
203
+ ws.onmessage?.({
204
+ data: JSON.stringify({
205
+ type: "mutation",
206
+ id,
207
+ operation,
208
+ input,
209
+ }),
210
+ });
211
+ });
212
+ },
213
+ };
214
+ }
215
+
216
+ // =============================================================================
217
+ // Test: Basic Operations
218
+ // =============================================================================
219
+
220
+ describe("E2E - Basic Operations", () => {
221
+ it("query without input", async () => {
222
+ const getUsers = query()
223
+ .returns([User])
224
+ .resolve(() => mockUsers);
225
+
226
+ const server = createServer({
227
+ entities: { User },
228
+ queries: { getUsers },
229
+ });
230
+
231
+ const client = createMockClient(server);
232
+ const result = await client.query("getUsers");
233
+ expect(result).toEqual(mockUsers);
234
+ });
235
+
236
+ it("query with input", async () => {
237
+ const getUser = query()
238
+ .input(z.object({ id: z.string() }))
239
+ .returns(User)
240
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
241
+
242
+ const server = createServer({
243
+ entities: { User },
244
+ queries: { getUser },
245
+ });
246
+
247
+ const client = createMockClient(server);
248
+ const result = await client.query("getUser", { id: "user-1" });
249
+ expect(result).toEqual(mockUsers[0]);
250
+ });
251
+
252
+ it("mutation", async () => {
253
+ const createUser = mutation()
254
+ .input(z.object({ name: z.string(), email: z.string() }))
255
+ .returns(User)
256
+ .resolve(({ input }) => ({
257
+ id: "user-new",
258
+ name: input.name,
259
+ email: input.email,
260
+ status: "online",
261
+ }));
262
+
263
+ const server = createServer({
264
+ entities: { User },
265
+ mutations: { createUser },
266
+ });
267
+
268
+ const client = createMockClient(server);
269
+ const result = await client.mutate("createUser", {
270
+ name: "Charlie",
271
+ email: "charlie@example.com",
272
+ });
273
+ expect(result).toMatchObject({ name: "Charlie", email: "charlie@example.com" });
274
+ });
275
+ });
276
+
277
+ // =============================================================================
278
+ // Test: Subscriptions
279
+ // =============================================================================
280
+
281
+ describe("E2E - Subscriptions", () => {
282
+ it("subscribe receives initial data", async () => {
283
+ const getUser = query()
284
+ .input(z.object({ id: z.string() }))
285
+ .returns(User)
286
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
287
+
288
+ const server = createServer({
289
+ entities: { User },
290
+ queries: { getUser },
291
+ });
292
+
293
+ const client = createMockClient(server);
294
+ const received: unknown[] = [];
295
+
296
+ client.subscribe("getUser", { id: "user-1" }, "*", {
297
+ onData: (data) => received.push(data),
298
+ onUpdate: () => {},
299
+ onError: () => {},
300
+ onComplete: () => {},
301
+ });
302
+
303
+ await new Promise((r) => setTimeout(r, 50));
304
+
305
+ expect(received.length).toBeGreaterThanOrEqual(1);
306
+ expect(received[0]).toMatchObject({ name: "Alice" });
307
+ });
308
+
309
+ it("subscribe receives updates via ctx.emit", async () => {
310
+ let emitFn: ((data: unknown) => void) | null = null;
311
+
312
+ const watchUser = query()
313
+ .input(z.object({ id: z.string() }))
314
+ .returns(User)
315
+ .resolve(({ input, ctx }) => {
316
+ emitFn = ctx.emit;
317
+ return mockUsers.find((u) => u.id === input.id) ?? null;
318
+ });
319
+
320
+ const server = createServer({
321
+ entities: { User },
322
+ queries: { watchUser },
323
+ });
324
+
325
+ const client = createMockClient(server);
326
+ const received: unknown[] = [];
327
+
328
+ client.subscribe("watchUser", { id: "user-1" }, "*", {
329
+ onData: (data) => received.push(data),
330
+ onUpdate: () => {},
331
+ onError: () => {},
332
+ onComplete: () => {},
333
+ });
334
+
335
+ await new Promise((r) => setTimeout(r, 50));
336
+
337
+ // Initial data
338
+ expect(received.length).toBeGreaterThanOrEqual(1);
339
+ expect(received[0]).toMatchObject({ name: "Alice" });
340
+
341
+ const initialCount = received.length;
342
+
343
+ // Emit update
344
+ emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "away" });
345
+
346
+ await new Promise((r) => setTimeout(r, 50));
347
+
348
+ // Should receive update
349
+ expect(received.length).toBeGreaterThan(initialCount);
350
+ });
351
+
352
+ it("unsubscribe stops receiving updates", async () => {
353
+ let emitFn: ((data: unknown) => void) | null = null;
354
+
355
+ const watchUser = query()
356
+ .input(z.object({ id: z.string() }))
357
+ .returns(User)
358
+ .resolve(({ input, ctx }) => {
359
+ emitFn = ctx.emit;
360
+ return mockUsers.find((u) => u.id === input.id) ?? null;
361
+ });
362
+
363
+ const server = createServer({
364
+ entities: { User },
365
+ queries: { watchUser },
366
+ });
367
+
368
+ const client = createMockClient(server);
369
+ const received: unknown[] = [];
370
+
371
+ const sub = client.subscribe("watchUser", { id: "user-1" }, "*", {
372
+ onData: (data) => received.push(data),
373
+ onUpdate: () => {},
374
+ onError: () => {},
375
+ onComplete: () => {},
376
+ });
377
+
378
+ await new Promise((r) => setTimeout(r, 50));
379
+
380
+ // Initial data
381
+ const initialCount = received.length;
382
+ expect(initialCount).toBeGreaterThanOrEqual(1);
383
+
384
+ // Unsubscribe
385
+ sub.unsubscribe();
386
+
387
+ // Emit update after unsubscribe
388
+ emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "away" });
389
+
390
+ await new Promise((r) => setTimeout(r, 50));
391
+
392
+ // Should not receive after unsubscribe
393
+ expect(received.length).toBe(initialCount);
394
+ });
395
+ });
396
+
397
+ // =============================================================================
398
+ // Test: Server API
399
+ // =============================================================================
400
+
401
+ describe("E2E - Server API", () => {
402
+ it("executes queries via mock client", async () => {
403
+ const whoami = query()
404
+ .returns(User)
405
+ .resolve(() => mockUsers[0]);
406
+
407
+ const searchUsers = query()
408
+ .input(z.object({ query: z.string() }))
409
+ .returns([User])
410
+ .resolve(({ input }) =>
411
+ mockUsers.filter((u) => u.name.toLowerCase().includes(input.query.toLowerCase())),
412
+ );
413
+
414
+ const server = createServer({
415
+ entities: { User },
416
+ queries: { whoami, searchUsers },
417
+ });
418
+
419
+ const client = createMockClient(server);
420
+
421
+ const me = await client.query("whoami");
422
+ expect(me).toMatchObject({ name: "Alice" });
423
+
424
+ const users = await client.query("searchUsers", { query: "bob" });
425
+ expect(users).toEqual([mockUsers[1]]);
426
+ });
427
+
428
+ it("executes mutations via mock client", async () => {
429
+ const updateStatus = mutation()
430
+ .input(z.object({ id: z.string(), status: z.string() }))
431
+ .returns(User)
432
+ .resolve(({ input }) => {
433
+ const user = mockUsers.find((u) => u.id === input.id);
434
+ if (!user) throw new Error("User not found");
435
+ return { ...user, status: input.status };
436
+ });
437
+
438
+ const server = createServer({
439
+ entities: { User },
440
+ mutations: { updateStatus },
441
+ });
442
+
443
+ const client = createMockClient(server);
444
+ const result = await client.mutate("updateStatus", { id: "user-1", status: "busy" });
445
+ expect(result).toMatchObject({ status: "busy" });
446
+ });
447
+ });
448
+
449
+ // =============================================================================
450
+ // Test: Cleanup (ctx.onCleanup)
451
+ // =============================================================================
452
+
453
+ describe("E2E - Cleanup", () => {
454
+ it("calls cleanup on unsubscribe", async () => {
455
+ let cleanedUp = false;
456
+
457
+ const watchUser = query()
458
+ .input(z.object({ id: z.string() }))
459
+ .returns(User)
460
+ .resolve(({ input, ctx }) => {
461
+ ctx.onCleanup(() => {
462
+ cleanedUp = true;
463
+ });
464
+ return mockUsers.find((u) => u.id === input.id) ?? null;
465
+ });
466
+
467
+ const server = createServer({
468
+ entities: { User },
469
+ queries: { watchUser },
470
+ });
471
+
472
+ const client = createMockClient(server);
473
+
474
+ const sub = client.subscribe("watchUser", { id: "user-1" }, "*", {
475
+ onData: () => {},
476
+ onUpdate: () => {},
477
+ onError: () => {},
478
+ onComplete: () => {},
479
+ });
480
+
481
+ await new Promise((r) => setTimeout(r, 50));
482
+
483
+ // Unsubscribe should trigger cleanup
484
+ sub.unsubscribe();
485
+ expect(cleanedUp).toBe(true);
486
+ });
487
+ });
488
+
489
+ // =============================================================================
490
+ // Test: GraphStateManager Integration
491
+ // =============================================================================
492
+
493
+ describe("E2E - GraphStateManager", () => {
494
+ it("mutation updates are broadcast to subscribers", async () => {
495
+ let emitFn: ((data: unknown) => void) | null = null;
496
+
497
+ const getUser = query()
498
+ .input(z.object({ id: z.string() }))
499
+ .returns(User)
500
+ .resolve(({ input, ctx }) => {
501
+ emitFn = ctx.emit;
502
+ return mockUsers.find((u) => u.id === input.id) ?? null;
503
+ });
504
+
505
+ const updateUser = mutation()
506
+ .input(z.object({ id: z.string(), name: z.string() }))
507
+ .returns(User)
508
+ .resolve(({ input }) => {
509
+ const user = mockUsers.find((u) => u.id === input.id);
510
+ if (!user) throw new Error("Not found");
511
+ return { ...user, name: input.name };
512
+ });
513
+
514
+ const server = createServer({
515
+ entities: { User },
516
+ queries: { getUser },
517
+ mutations: { updateUser },
518
+ });
519
+
520
+ const client = createMockClient(server);
521
+ const received: unknown[] = [];
522
+
523
+ // Subscribe to user
524
+ client.subscribe("getUser", { id: "user-1" }, "*", {
525
+ onData: (data) => received.push(data),
526
+ onUpdate: () => {},
527
+ onError: () => {},
528
+ onComplete: () => {},
529
+ });
530
+
531
+ await new Promise((r) => setTimeout(r, 50));
532
+
533
+ // Initial data received
534
+ expect(received.length).toBeGreaterThanOrEqual(1);
535
+
536
+ // Execute mutation
537
+ await client.mutate("updateUser", { id: "user-1", name: "Alice Updated" });
538
+
539
+ // If using ctx.emit in the subscription, we can manually broadcast
540
+ emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "online" });
541
+
542
+ await new Promise((r) => setTimeout(r, 50));
543
+
544
+ // Should have received update
545
+ expect(received.length).toBeGreaterThan(1);
546
+ });
547
+ });
548
+
549
+ // =============================================================================
550
+ // Test: Entity Resolvers and Nested Selection
551
+ // =============================================================================
552
+
553
+ describe("E2E - Entity Resolvers", () => {
554
+ it("executes entity resolvers for nested selection via $select", async () => {
555
+ // Mock data
556
+ const users = [
557
+ { id: "user-1", name: "Alice", email: "alice@example.com" },
558
+ { id: "user-2", name: "Bob", email: "bob@example.com" },
559
+ ];
560
+
561
+ const posts = [
562
+ { id: "post-1", title: "Hello World", content: "First post", authorId: "user-1" },
563
+ { id: "post-2", title: "Second Post", content: "More content", authorId: "user-1" },
564
+ ];
565
+
566
+ const getUser = query()
567
+ .input(z.object({ id: z.string() }))
568
+ .returns(User)
569
+ .resolve(({ input }) => users.find((u) => u.id === input.id) ?? null);
570
+
571
+ // Create entity resolvers for User.posts
572
+ const resolvers = {
573
+ getResolver: (entityName: string, fieldName: string) => {
574
+ if (entityName === "User" && fieldName === "posts") {
575
+ return (user: { id: string }) => posts.filter((p) => p.authorId === user.id);
576
+ }
577
+ return undefined;
578
+ },
579
+ };
580
+
581
+ const server = createServer({
582
+ entities: { User, Post },
583
+ queries: { getUser },
584
+ resolvers: resolvers as any,
585
+ });
586
+
587
+ // Test with $select for nested posts
588
+ const result = await server.executeQuery("getUser", {
589
+ id: "user-1",
590
+ $select: {
591
+ name: true,
592
+ posts: {
593
+ select: {
594
+ title: true,
595
+ },
596
+ },
597
+ },
598
+ });
599
+
600
+ expect(result).toMatchObject({
601
+ id: "user-1",
602
+ name: "Alice",
603
+ posts: [
604
+ { id: "post-1", title: "Hello World" },
605
+ { id: "post-2", title: "Second Post" },
606
+ ],
607
+ });
608
+ });
609
+
610
+ it("handles DataLoader batching for entity resolvers", async () => {
611
+ // Track batch calls
612
+ let batchCallCount = 0;
613
+
614
+ const users = [
615
+ { id: "user-1", name: "Alice" },
616
+ { id: "user-2", name: "Bob" },
617
+ ];
618
+
619
+ const posts = [
620
+ { id: "post-1", title: "Post 1", authorId: "user-1" },
621
+ { id: "post-2", title: "Post 2", authorId: "user-2" },
622
+ ];
623
+
624
+ const getUsers = query()
625
+ .returns([User])
626
+ .resolve(() => users);
627
+
628
+ // Create batch resolver for User.posts (object with batch property)
629
+ const resolvers = {
630
+ getResolver: (entityName: string, fieldName: string) => {
631
+ if (entityName === "User" && fieldName === "posts") {
632
+ // Return batch resolver object (not a function with batch attached)
633
+ return {
634
+ batch: async (parents: { id: string }[]) => {
635
+ batchCallCount++;
636
+ return parents.map((parent) => posts.filter((p) => p.authorId === parent.id));
637
+ },
638
+ };
639
+ }
640
+ return undefined;
641
+ },
642
+ };
643
+
644
+ const server = createServer({
645
+ entities: { User, Post },
646
+ queries: { getUsers },
647
+ resolvers: resolvers as any,
648
+ });
649
+
650
+ // Execute query with nested selection for all users
651
+ const result = await server.executeQuery("getUsers", {
652
+ $select: {
653
+ name: true,
654
+ posts: {
655
+ select: {
656
+ title: true,
657
+ },
658
+ },
659
+ },
660
+ });
661
+
662
+ // Should have batched the posts resolution into a single call
663
+ expect(batchCallCount).toBe(1);
664
+ expect(result).toHaveLength(2);
665
+ });
666
+ });