@sylphx/lens-server 1.11.2 → 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.
- package/dist/index.d.ts +1244 -260
- package/dist/index.js +1700 -1158
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +44 -0
- package/src/server/types.ts +289 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
|
@@ -1,2361 +1,424 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @sylphx/lens-server - Server Tests
|
|
3
3
|
*
|
|
4
|
-
* Tests for
|
|
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,
|
|
9
|
+
import { entity, mutation, query, router } from "@sylphx/lens-core";
|
|
9
10
|
import { z } from "zod";
|
|
10
|
-
import {
|
|
11
|
+
import { optimisticPlugin } from "../plugin/optimistic.js";
|
|
12
|
+
import { createApp } from "./create.js";
|
|
11
13
|
|
|
12
14
|
// =============================================================================
|
|
13
|
-
// Test
|
|
15
|
+
// Test Entities
|
|
14
16
|
// =============================================================================
|
|
15
17
|
|
|
16
|
-
// Entities
|
|
17
18
|
const User = entity("User", {
|
|
18
|
-
id:
|
|
19
|
-
name:
|
|
20
|
-
email:
|
|
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
|
|
25
|
+
// Test Queries and Mutations
|
|
57
26
|
// =============================================================================
|
|
58
27
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
//
|
|
64
|
+
// createApp Tests
|
|
98
65
|
// =============================================================================
|
|
99
66
|
|
|
100
|
-
describe("
|
|
101
|
-
it("
|
|
102
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
177
|
-
name: "Charlie",
|
|
178
|
-
email: "charlie@example.com",
|
|
179
|
-
});
|
|
88
|
+
const server = createApp({ router: appRouter });
|
|
180
89
|
|
|
181
|
-
expect(
|
|
182
|
-
|
|
183
|
-
|
|
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("
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
.
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
);
|
|
101
|
+
const metadata = server.getMetadata();
|
|
102
|
+
expect(metadata.version).toBe("2.0.0");
|
|
202
103
|
});
|
|
203
104
|
|
|
204
|
-
it("
|
|
205
|
-
const server =
|
|
206
|
-
entities: { User },
|
|
207
|
-
mutations: {},
|
|
208
|
-
});
|
|
105
|
+
it("creates server with empty config", () => {
|
|
106
|
+
const server = createApp({});
|
|
209
107
|
|
|
210
|
-
|
|
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
|
-
//
|
|
116
|
+
// getMetadata Tests
|
|
216
117
|
// =============================================================================
|
|
217
118
|
|
|
218
|
-
describe("
|
|
219
|
-
it("
|
|
220
|
-
const server =
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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(
|
|
234
|
-
|
|
235
|
-
expect(
|
|
236
|
-
expect(
|
|
237
|
-
expect(
|
|
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("
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
expect(
|
|
261
|
-
expect(
|
|
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("
|
|
266
|
-
const
|
|
267
|
-
|
|
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
|
-
|
|
155
|
+
const metadata = server.getMetadata();
|
|
290
156
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
expect(
|
|
294
|
-
expect(
|
|
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
|
|
299
|
-
const
|
|
300
|
-
|
|
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
|
|
304
|
-
server.
|
|
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(
|
|
310
|
-
|
|
311
|
-
expect(
|
|
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
|
-
//
|
|
184
|
+
// execute Tests
|
|
318
185
|
// =============================================================================
|
|
319
186
|
|
|
320
|
-
describe("
|
|
321
|
-
it("
|
|
322
|
-
const
|
|
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
|
|
371
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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("
|
|
396
|
-
const
|
|
397
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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("
|
|
454
|
-
const
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
queries: {
|
|
239
|
+
it("returns error for invalid input", async () => {
|
|
240
|
+
const server = createApp({
|
|
241
|
+
queries: { getUser },
|
|
518
242
|
});
|
|
519
243
|
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
527
|
-
expect(
|
|
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("
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
263
|
+
const queryResult = await server.execute({
|
|
264
|
+
path: "user.get",
|
|
265
|
+
input: { id: "456" },
|
|
560
266
|
});
|
|
561
267
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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 =
|
|
645
|
-
|
|
646
|
-
queries: { liveQuery },
|
|
293
|
+
const server = createApp({
|
|
294
|
+
queries: { errorQuery },
|
|
647
295
|
});
|
|
648
296
|
|
|
649
|
-
const
|
|
650
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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("
|
|
677
|
-
|
|
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
|
|
692
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
321
|
+
// Context Tests
|
|
719
322
|
// =============================================================================
|
|
720
323
|
|
|
721
|
-
describe("
|
|
722
|
-
it("
|
|
723
|
-
let
|
|
324
|
+
describe("context", () => {
|
|
325
|
+
it("passes context to resolvers", async () => {
|
|
326
|
+
let capturedContext: unknown = null;
|
|
724
327
|
|
|
725
|
-
const
|
|
726
|
-
.
|
|
328
|
+
const contextQuery = query()
|
|
329
|
+
.input(z.object({ id: z.string() }))
|
|
727
330
|
.resolve(({ ctx }) => {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
});
|
|
731
|
-
return mockUsers[0];
|
|
331
|
+
capturedContext = ctx;
|
|
332
|
+
return { id: "1", name: "test" };
|
|
732
333
|
});
|
|
733
334
|
|
|
734
|
-
const server =
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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("
|
|
801
|
-
let
|
|
351
|
+
it("supports async context factory", async () => {
|
|
352
|
+
let capturedContext: unknown = null;
|
|
802
353
|
|
|
803
|
-
const
|
|
804
|
-
.
|
|
354
|
+
const contextQuery = query()
|
|
355
|
+
.input(z.object({ id: z.string() }))
|
|
805
356
|
.resolve(({ ctx }) => {
|
|
806
|
-
|
|
807
|
-
|
|
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 =
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
820
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
381
|
+
// Selection Tests
|
|
846
382
|
// =============================================================================
|
|
847
383
|
|
|
848
|
-
describe("
|
|
849
|
-
it("
|
|
850
|
-
const
|
|
851
|
-
|
|
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({
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
.resolve(() => {
|
|
895
|
-
throw new Error("Test error");
|
|
896
|
-
});
|
|
406
|
+
// =============================================================================
|
|
407
|
+
// Type Inference Tests
|
|
408
|
+
// =============================================================================
|
|
897
409
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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.
|
|
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
|
-
|
|
2357
|
-
expect((
|
|
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
|
});
|