@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.
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1437 -0
- package/dist/server/create.d.ts +226 -0
- package/dist/server/create.d.ts.map +1 -0
- package/dist/sse/handler.d.ts +78 -0
- package/dist/sse/handler.d.ts.map +1 -0
- package/dist/state/graph-state-manager.d.ts +146 -0
- package/dist/state/graph-state-manager.d.ts.map +1 -0
- package/dist/state/index.d.ts +7 -0
- package/dist/state/index.d.ts.map +1 -0
- package/package.json +39 -0
- package/src/e2e/server.test.ts +666 -0
- package/src/index.ts +67 -0
- package/src/server/create.test.ts +807 -0
- package/src/server/create.ts +1536 -0
- package/src/sse/handler.ts +185 -0
- package/src/state/graph-state-manager.test.ts +334 -0
- package/src/state/graph-state-manager.ts +443 -0
- package/src/state/index.ts +16 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Server Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for Lens server operations and GraphStateManager integration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { entity, mutation, query, t } from "@sylphx/lens-core";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { type WebSocketLike, createServer } from "./create";
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Test Fixtures
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
// Entities
|
|
17
|
+
const User = entity("User", {
|
|
18
|
+
id: t.id(),
|
|
19
|
+
name: t.string(),
|
|
20
|
+
email: t.string(),
|
|
21
|
+
bio: t.string().nullable(),
|
|
22
|
+
});
|
|
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
|
+
// =============================================================================
|
|
56
|
+
// Test: Server Creation
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
describe("createServer", () => {
|
|
60
|
+
it("creates a server instance", () => {
|
|
61
|
+
const server = createServer({
|
|
62
|
+
entities: { User, Post },
|
|
63
|
+
});
|
|
64
|
+
|
|
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
|
+
});
|
|
72
|
+
|
|
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
|
+
});
|
|
83
|
+
|
|
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
|
+
});
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// Test: Query Execution
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
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({
|
|
139
|
+
entities: { User },
|
|
140
|
+
queries: { getUser },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await expect(server.executeQuery("getUser", { id: 123 as unknown as string })).rejects.toThrow(
|
|
144
|
+
"Invalid input",
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("throws for unknown query", async () => {
|
|
149
|
+
const server = createServer({
|
|
150
|
+
entities: { User },
|
|
151
|
+
queries: {},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await expect(server.executeQuery("unknownQuery")).rejects.toThrow(
|
|
155
|
+
"Query not found: unknownQuery",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// =============================================================================
|
|
161
|
+
// Test: Mutation Execution
|
|
162
|
+
// =============================================================================
|
|
163
|
+
|
|
164
|
+
describe("executeMutation", () => {
|
|
165
|
+
it("executes a simple mutation", async () => {
|
|
166
|
+
const createUser = mutation()
|
|
167
|
+
.input(z.object({ name: z.string(), email: z.string() }))
|
|
168
|
+
.returns(User)
|
|
169
|
+
.resolve(({ input }) => ({
|
|
170
|
+
id: "user-new",
|
|
171
|
+
name: input.name,
|
|
172
|
+
email: input.email,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const server = createServer({
|
|
176
|
+
entities: { User },
|
|
177
|
+
mutations: { createUser },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const result = await server.executeMutation("createUser", {
|
|
181
|
+
name: "Charlie",
|
|
182
|
+
email: "charlie@example.com",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result).toEqual({
|
|
186
|
+
id: "user-new",
|
|
187
|
+
name: "Charlie",
|
|
188
|
+
email: "charlie@example.com",
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("validates mutation input", async () => {
|
|
193
|
+
const createUser = mutation()
|
|
194
|
+
.input(z.object({ name: z.string(), email: z.string().email() }))
|
|
195
|
+
.returns(User)
|
|
196
|
+
.resolve(({ input }) => ({ id: "new", ...input }));
|
|
197
|
+
|
|
198
|
+
const server = createServer({
|
|
199
|
+
entities: { User },
|
|
200
|
+
mutations: { createUser },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await expect(
|
|
204
|
+
server.executeMutation("createUser", { name: "Test", email: "invalid-email" }),
|
|
205
|
+
).rejects.toThrow("Invalid input");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("throws for unknown mutation", async () => {
|
|
209
|
+
const server = createServer({
|
|
210
|
+
entities: { User },
|
|
211
|
+
mutations: {},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await expect(server.executeMutation("unknownMutation", {})).rejects.toThrow(
|
|
215
|
+
"Mutation not found: unknownMutation",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// =============================================================================
|
|
221
|
+
// Test: WebSocket Protocol
|
|
222
|
+
// =============================================================================
|
|
223
|
+
|
|
224
|
+
describe("WebSocket Protocol", () => {
|
|
225
|
+
it("handles handshake message", () => {
|
|
226
|
+
const server = createServer({
|
|
227
|
+
entities: { User },
|
|
228
|
+
queries: {},
|
|
229
|
+
mutations: {},
|
|
230
|
+
version: "2.1.0",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const ws = createMockWs();
|
|
234
|
+
server.handleWebSocket(ws);
|
|
235
|
+
|
|
236
|
+
// Simulate handshake
|
|
237
|
+
ws.onmessage?.({ data: JSON.stringify({ type: "handshake", id: "hs-1" }) });
|
|
238
|
+
|
|
239
|
+
expect(ws.messages.length).toBe(1);
|
|
240
|
+
const response = JSON.parse(ws.messages[0]);
|
|
241
|
+
expect(response.type).toBe("handshake");
|
|
242
|
+
expect(response.id).toBe("hs-1");
|
|
243
|
+
expect(response.version).toBe("2.1.0");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("handles query message", async () => {
|
|
247
|
+
const getUsers = query()
|
|
248
|
+
.returns([User])
|
|
249
|
+
.resolve(() => mockUsers);
|
|
250
|
+
|
|
251
|
+
const server = createServer({
|
|
252
|
+
entities: { User },
|
|
253
|
+
queries: { getUsers },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const ws = createMockWs();
|
|
257
|
+
server.handleWebSocket(ws);
|
|
258
|
+
|
|
259
|
+
// Simulate query
|
|
260
|
+
ws.onmessage?.({ data: JSON.stringify({ type: "query", id: "q-1", operation: "getUsers" }) });
|
|
261
|
+
|
|
262
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
263
|
+
|
|
264
|
+
expect(ws.messages.length).toBe(1);
|
|
265
|
+
const response = JSON.parse(ws.messages[0]);
|
|
266
|
+
expect(response.type).toBe("result");
|
|
267
|
+
expect(response.id).toBe("q-1");
|
|
268
|
+
expect(response.data).toEqual(mockUsers);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("handles mutation message", async () => {
|
|
272
|
+
const createUser = mutation()
|
|
273
|
+
.input(z.object({ name: z.string() }))
|
|
274
|
+
.returns(User)
|
|
275
|
+
.resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
|
|
276
|
+
|
|
277
|
+
const server = createServer({
|
|
278
|
+
entities: { User },
|
|
279
|
+
mutations: { createUser },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const ws = createMockWs();
|
|
283
|
+
server.handleWebSocket(ws);
|
|
284
|
+
|
|
285
|
+
// Simulate mutation
|
|
286
|
+
ws.onmessage?.({
|
|
287
|
+
data: JSON.stringify({
|
|
288
|
+
type: "mutation",
|
|
289
|
+
id: "m-1",
|
|
290
|
+
operation: "createUser",
|
|
291
|
+
input: { name: "Test" },
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
296
|
+
|
|
297
|
+
expect(ws.messages.length).toBe(1);
|
|
298
|
+
const response = JSON.parse(ws.messages[0]);
|
|
299
|
+
expect(response.type).toBe("result");
|
|
300
|
+
expect(response.id).toBe("m-1");
|
|
301
|
+
expect(response.data).toEqual({ id: "new", name: "Test", email: "" });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("handles parse error", () => {
|
|
305
|
+
const server = createServer({
|
|
306
|
+
entities: { User },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const ws = createMockWs();
|
|
310
|
+
server.handleWebSocket(ws);
|
|
311
|
+
|
|
312
|
+
// Send invalid JSON
|
|
313
|
+
ws.onmessage?.({ data: "invalid json" });
|
|
314
|
+
|
|
315
|
+
expect(ws.messages.length).toBe(1);
|
|
316
|
+
const response = JSON.parse(ws.messages[0]);
|
|
317
|
+
expect(response.type).toBe("error");
|
|
318
|
+
expect(response.error.code).toBe("PARSE_ERROR");
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// =============================================================================
|
|
323
|
+
// Test: Subscribe Protocol (Field-Level)
|
|
324
|
+
// =============================================================================
|
|
325
|
+
|
|
326
|
+
describe("Subscribe Protocol", () => {
|
|
327
|
+
it("handles subscribe message", async () => {
|
|
328
|
+
const getUser = query()
|
|
329
|
+
.input(z.object({ id: z.string() }))
|
|
330
|
+
.returns(User)
|
|
331
|
+
.resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
|
|
332
|
+
|
|
333
|
+
const server = createServer({
|
|
334
|
+
entities: { User },
|
|
335
|
+
queries: { getUser },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const ws = createMockWs();
|
|
339
|
+
server.handleWebSocket(ws);
|
|
340
|
+
|
|
341
|
+
// Subscribe to user
|
|
342
|
+
ws.onmessage?.({
|
|
343
|
+
data: JSON.stringify({
|
|
344
|
+
type: "subscribe",
|
|
345
|
+
id: "sub-1",
|
|
346
|
+
operation: "getUser",
|
|
347
|
+
input: { id: "user-1" },
|
|
348
|
+
fields: ["name", "email"],
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
353
|
+
|
|
354
|
+
// Should receive messages (either from GraphStateManager or operation-level)
|
|
355
|
+
expect(ws.messages.length).toBeGreaterThan(0);
|
|
356
|
+
|
|
357
|
+
// Find the operation-level data message (has subscription id)
|
|
358
|
+
const dataMessage = ws.messages
|
|
359
|
+
.map((m) => JSON.parse(m))
|
|
360
|
+
.find((m) => m.type === "data" && m.id === "sub-1");
|
|
361
|
+
|
|
362
|
+
// Should have received operation-level data
|
|
363
|
+
expect(dataMessage).toBeDefined();
|
|
364
|
+
expect(dataMessage.data).toMatchObject({ name: "Alice" });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("handles unsubscribe message", async () => {
|
|
368
|
+
const getUser = query()
|
|
369
|
+
.input(z.object({ id: z.string() }))
|
|
370
|
+
.returns(User)
|
|
371
|
+
.resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
|
|
372
|
+
|
|
373
|
+
const server = createServer({
|
|
374
|
+
entities: { User },
|
|
375
|
+
queries: { getUser },
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const ws = createMockWs();
|
|
379
|
+
server.handleWebSocket(ws);
|
|
380
|
+
|
|
381
|
+
// Subscribe
|
|
382
|
+
ws.onmessage?.({
|
|
383
|
+
data: JSON.stringify({
|
|
384
|
+
type: "subscribe",
|
|
385
|
+
id: "sub-1",
|
|
386
|
+
operation: "getUser",
|
|
387
|
+
input: { id: "user-1" },
|
|
388
|
+
fields: "*",
|
|
389
|
+
}),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
393
|
+
|
|
394
|
+
// Unsubscribe
|
|
395
|
+
ws.onmessage?.({
|
|
396
|
+
data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Should not throw
|
|
400
|
+
expect(true).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("handles updateFields message", async () => {
|
|
404
|
+
const getUser = query()
|
|
405
|
+
.input(z.object({ id: z.string() }))
|
|
406
|
+
.returns(User)
|
|
407
|
+
.resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
|
|
408
|
+
|
|
409
|
+
const server = createServer({
|
|
410
|
+
entities: { User },
|
|
411
|
+
queries: { getUser },
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const ws = createMockWs();
|
|
415
|
+
server.handleWebSocket(ws);
|
|
416
|
+
|
|
417
|
+
// Subscribe with partial fields
|
|
418
|
+
ws.onmessage?.({
|
|
419
|
+
data: JSON.stringify({
|
|
420
|
+
type: "subscribe",
|
|
421
|
+
id: "sub-1",
|
|
422
|
+
operation: "getUser",
|
|
423
|
+
input: { id: "user-1" },
|
|
424
|
+
fields: ["name"],
|
|
425
|
+
}),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
429
|
+
|
|
430
|
+
// Update fields
|
|
431
|
+
ws.onmessage?.({
|
|
432
|
+
data: JSON.stringify({
|
|
433
|
+
type: "updateFields",
|
|
434
|
+
id: "sub-1",
|
|
435
|
+
addFields: ["email"],
|
|
436
|
+
removeFields: [],
|
|
437
|
+
}),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Should not throw
|
|
441
|
+
expect(true).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// =============================================================================
|
|
446
|
+
// Test: GraphStateManager Integration
|
|
447
|
+
// =============================================================================
|
|
448
|
+
|
|
449
|
+
describe("GraphStateManager Integration", () => {
|
|
450
|
+
it("provides access to state manager", () => {
|
|
451
|
+
const server = createServer({
|
|
452
|
+
entities: { User },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const stateManager = server.getStateManager();
|
|
456
|
+
expect(stateManager).toBeDefined();
|
|
457
|
+
expect(typeof stateManager.emit).toBe("function");
|
|
458
|
+
expect(typeof stateManager.subscribe).toBe("function");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("tracks client state after subscribe", async () => {
|
|
462
|
+
const getUser = query()
|
|
463
|
+
.input(z.object({ id: z.string() }))
|
|
464
|
+
.returns(User)
|
|
465
|
+
.resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
|
|
466
|
+
|
|
467
|
+
const server = createServer({
|
|
468
|
+
entities: { User },
|
|
469
|
+
queries: { getUser },
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const stateManager = server.getStateManager();
|
|
473
|
+
|
|
474
|
+
const ws = createMockWs();
|
|
475
|
+
server.handleWebSocket(ws);
|
|
476
|
+
|
|
477
|
+
// Subscribe
|
|
478
|
+
ws.onmessage?.({
|
|
479
|
+
data: JSON.stringify({
|
|
480
|
+
type: "subscribe",
|
|
481
|
+
id: "sub-1",
|
|
482
|
+
operation: "getUser",
|
|
483
|
+
input: { id: "user-1" },
|
|
484
|
+
fields: "*",
|
|
485
|
+
}),
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
489
|
+
|
|
490
|
+
// State manager should have the client registered
|
|
491
|
+
const stats = stateManager.getStats();
|
|
492
|
+
expect(stats.clients).toBe(1);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("removes client state on disconnect", async () => {
|
|
496
|
+
const server = createServer({
|
|
497
|
+
entities: { User },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const stateManager = server.getStateManager();
|
|
501
|
+
|
|
502
|
+
const ws = createMockWs();
|
|
503
|
+
server.handleWebSocket(ws);
|
|
504
|
+
|
|
505
|
+
// Simulate disconnect
|
|
506
|
+
ws.onclose?.();
|
|
507
|
+
|
|
508
|
+
const stats = stateManager.getStats();
|
|
509
|
+
expect(stats.clients).toBe(0);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// =============================================================================
|
|
514
|
+
// Test: HTTP Handler
|
|
515
|
+
// =============================================================================
|
|
516
|
+
|
|
517
|
+
describe("handleRequest", () => {
|
|
518
|
+
it("handles query request", async () => {
|
|
519
|
+
const getUsers = query()
|
|
520
|
+
.returns([User])
|
|
521
|
+
.resolve(() => mockUsers);
|
|
522
|
+
|
|
523
|
+
const server = createServer({
|
|
524
|
+
entities: { User },
|
|
525
|
+
queries: { getUsers },
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const request = new Request("http://localhost/api", {
|
|
529
|
+
method: "POST",
|
|
530
|
+
headers: { "Content-Type": "application/json" },
|
|
531
|
+
body: JSON.stringify({ type: "query", operation: "getUsers" }),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const response = await server.handleRequest(request);
|
|
535
|
+
expect(response.status).toBe(200);
|
|
536
|
+
|
|
537
|
+
const body = await response.json();
|
|
538
|
+
expect(body.data).toEqual(mockUsers);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("handles mutation request", async () => {
|
|
542
|
+
const createUser = mutation()
|
|
543
|
+
.input(z.object({ name: z.string() }))
|
|
544
|
+
.returns(User)
|
|
545
|
+
.resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
|
|
546
|
+
|
|
547
|
+
const server = createServer({
|
|
548
|
+
entities: { User },
|
|
549
|
+
mutations: { createUser },
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const request = new Request("http://localhost/api", {
|
|
553
|
+
method: "POST",
|
|
554
|
+
headers: { "Content-Type": "application/json" },
|
|
555
|
+
body: JSON.stringify({ type: "mutation", operation: "createUser", input: { name: "Test" } }),
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const response = await server.handleRequest(request);
|
|
559
|
+
expect(response.status).toBe(200);
|
|
560
|
+
|
|
561
|
+
const body = await response.json();
|
|
562
|
+
expect(body.data).toEqual({ id: "new", name: "Test", email: "" });
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("rejects non-POST requests", async () => {
|
|
566
|
+
const server = createServer({
|
|
567
|
+
entities: { User },
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const request = new Request("http://localhost/api", { method: "GET" });
|
|
571
|
+
const response = await server.handleRequest(request);
|
|
572
|
+
|
|
573
|
+
expect(response.status).toBe(405);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// =============================================================================
|
|
578
|
+
// Test: Streaming (Async Generator) Support
|
|
579
|
+
// =============================================================================
|
|
580
|
+
|
|
581
|
+
describe("Streaming Support", () => {
|
|
582
|
+
it("handles async generator query", async () => {
|
|
583
|
+
const streamQuery = query()
|
|
584
|
+
.returns(User)
|
|
585
|
+
.resolve(async function* () {
|
|
586
|
+
yield mockUsers[0];
|
|
587
|
+
yield mockUsers[1];
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const server = createServer({
|
|
591
|
+
entities: { User },
|
|
592
|
+
queries: { streamQuery },
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// executeQuery returns first value
|
|
596
|
+
const result = await server.executeQuery("streamQuery");
|
|
597
|
+
expect(result).toEqual(mockUsers[0]);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("streams values via WebSocket subscribe", async () => {
|
|
601
|
+
let yieldCount = 0;
|
|
602
|
+
|
|
603
|
+
const streamQuery = query()
|
|
604
|
+
.returns(User)
|
|
605
|
+
.resolve(async function* () {
|
|
606
|
+
yieldCount++;
|
|
607
|
+
yield mockUsers[0];
|
|
608
|
+
yieldCount++;
|
|
609
|
+
yield mockUsers[1];
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const server = createServer({
|
|
613
|
+
entities: { User },
|
|
614
|
+
queries: { streamQuery },
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const ws = createMockWs();
|
|
618
|
+
server.handleWebSocket(ws);
|
|
619
|
+
|
|
620
|
+
// Subscribe to stream
|
|
621
|
+
ws.onmessage?.({
|
|
622
|
+
data: JSON.stringify({
|
|
623
|
+
type: "subscribe",
|
|
624
|
+
id: "sub-1",
|
|
625
|
+
operation: "streamQuery",
|
|
626
|
+
fields: "*",
|
|
627
|
+
}),
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
631
|
+
|
|
632
|
+
expect(yieldCount).toBe(2);
|
|
633
|
+
expect(ws.messages.length).toBeGreaterThanOrEqual(1);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// =============================================================================
|
|
638
|
+
// Test: Minimum Transfer (Diff Computation)
|
|
639
|
+
// =============================================================================
|
|
640
|
+
|
|
641
|
+
describe("Minimum Transfer", () => {
|
|
642
|
+
it("sends initial data on subscribe", async () => {
|
|
643
|
+
let emitFn: ((data: unknown) => void) | null = null;
|
|
644
|
+
|
|
645
|
+
const liveQuery = query()
|
|
646
|
+
.returns(User)
|
|
647
|
+
.resolve(({ ctx }) => {
|
|
648
|
+
emitFn = ctx.emit;
|
|
649
|
+
return { id: "1", name: "Alice", email: "alice@example.com" };
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const server = createServer({
|
|
653
|
+
entities: { User },
|
|
654
|
+
queries: { liveQuery },
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const ws = createMockWs();
|
|
658
|
+
server.handleWebSocket(ws);
|
|
659
|
+
|
|
660
|
+
ws.onmessage?.({
|
|
661
|
+
data: JSON.stringify({
|
|
662
|
+
type: "subscribe",
|
|
663
|
+
id: "sub-1",
|
|
664
|
+
operation: "liveQuery",
|
|
665
|
+
fields: "*",
|
|
666
|
+
}),
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
670
|
+
|
|
671
|
+
// First message should have data
|
|
672
|
+
expect(ws.messages.length).toBeGreaterThan(0);
|
|
673
|
+
const firstUpdate = JSON.parse(ws.messages[0]);
|
|
674
|
+
// Can be "data" or "update" with value strategy for initial
|
|
675
|
+
expect(["data", "update"]).toContain(firstUpdate.type);
|
|
676
|
+
// Verify we got the data
|
|
677
|
+
if (firstUpdate.type === "data") {
|
|
678
|
+
expect(firstUpdate.data).toMatchObject({ name: "Alice" });
|
|
679
|
+
} else {
|
|
680
|
+
expect(firstUpdate.updates).toBeDefined();
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("sends updates via ctx.emit", async () => {
|
|
685
|
+
let emitFn: ((data: unknown) => void) | null = null;
|
|
686
|
+
|
|
687
|
+
const liveQuery = query()
|
|
688
|
+
.returns(User)
|
|
689
|
+
.resolve(({ ctx }) => {
|
|
690
|
+
emitFn = ctx.emit;
|
|
691
|
+
return { id: "1", name: "Alice", email: "alice@example.com" };
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const server = createServer({
|
|
695
|
+
entities: { User },
|
|
696
|
+
queries: { liveQuery },
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const ws = createMockWs();
|
|
700
|
+
server.handleWebSocket(ws);
|
|
701
|
+
|
|
702
|
+
ws.onmessage?.({
|
|
703
|
+
data: JSON.stringify({
|
|
704
|
+
type: "subscribe",
|
|
705
|
+
id: "sub-1",
|
|
706
|
+
operation: "liveQuery",
|
|
707
|
+
fields: "*",
|
|
708
|
+
}),
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
712
|
+
|
|
713
|
+
const initialCount = ws.messages.length;
|
|
714
|
+
|
|
715
|
+
// Emit update with changed data
|
|
716
|
+
emitFn?.({ id: "1", name: "Bob", email: "bob@example.com" });
|
|
717
|
+
|
|
718
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
719
|
+
|
|
720
|
+
// Should have received additional messages
|
|
721
|
+
expect(ws.messages.length).toBeGreaterThanOrEqual(initialCount);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// =============================================================================
|
|
726
|
+
// Test: ctx.onCleanup
|
|
727
|
+
// =============================================================================
|
|
728
|
+
|
|
729
|
+
describe("ctx.onCleanup", () => {
|
|
730
|
+
it("calls cleanup function on unsubscribe", async () => {
|
|
731
|
+
let cleanedUp = false;
|
|
732
|
+
|
|
733
|
+
const liveQuery = query()
|
|
734
|
+
.returns(User)
|
|
735
|
+
.resolve(({ ctx }) => {
|
|
736
|
+
ctx.onCleanup(() => {
|
|
737
|
+
cleanedUp = true;
|
|
738
|
+
});
|
|
739
|
+
return mockUsers[0];
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const server = createServer({
|
|
743
|
+
entities: { User },
|
|
744
|
+
queries: { liveQuery },
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const ws = createMockWs();
|
|
748
|
+
server.handleWebSocket(ws);
|
|
749
|
+
|
|
750
|
+
// Subscribe
|
|
751
|
+
ws.onmessage?.({
|
|
752
|
+
data: JSON.stringify({
|
|
753
|
+
type: "subscribe",
|
|
754
|
+
id: "sub-1",
|
|
755
|
+
operation: "liveQuery",
|
|
756
|
+
fields: "*",
|
|
757
|
+
}),
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
761
|
+
|
|
762
|
+
// Unsubscribe
|
|
763
|
+
ws.onmessage?.({
|
|
764
|
+
data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
expect(cleanedUp).toBe(true);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("calls cleanup on client disconnect", async () => {
|
|
771
|
+
let cleanedUp = false;
|
|
772
|
+
|
|
773
|
+
const liveQuery = query()
|
|
774
|
+
.returns(User)
|
|
775
|
+
.resolve(({ ctx }) => {
|
|
776
|
+
ctx.onCleanup(() => {
|
|
777
|
+
cleanedUp = true;
|
|
778
|
+
});
|
|
779
|
+
return mockUsers[0];
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const server = createServer({
|
|
783
|
+
entities: { User },
|
|
784
|
+
queries: { liveQuery },
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const ws = createMockWs();
|
|
788
|
+
server.handleWebSocket(ws);
|
|
789
|
+
|
|
790
|
+
// Subscribe
|
|
791
|
+
ws.onmessage?.({
|
|
792
|
+
data: JSON.stringify({
|
|
793
|
+
type: "subscribe",
|
|
794
|
+
id: "sub-1",
|
|
795
|
+
operation: "liveQuery",
|
|
796
|
+
fields: "*",
|
|
797
|
+
}),
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
801
|
+
|
|
802
|
+
// Disconnect
|
|
803
|
+
ws.onclose?.();
|
|
804
|
+
|
|
805
|
+
expect(cleanedUp).toBe(true);
|
|
806
|
+
});
|
|
807
|
+
});
|