@typokit/server-native 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1303 @@
1
+ // @typokit/server-native — Integration Tests
2
+
3
+ import { describe, it, expect } from "@rstest/core";
4
+ import type {
5
+ CompiledRoute,
6
+ CompiledRouteTable,
7
+ ErrorResponse,
8
+ HandlerMap,
9
+ MiddlewareChain,
10
+ SerializerMap,
11
+ TypoKitRequest,
12
+ ValidatorMap,
13
+ } from "@typokit/types";
14
+ import type { Server } from "node:http";
15
+ import {
16
+ nativeServer,
17
+ runValidators,
18
+ serializeResponse,
19
+ validationErrorResponse,
20
+ } from "./index.js";
21
+
22
+ // ─── Test Helpers ────────────────────────────────────────────
23
+
24
+ function makeRouteTable(): CompiledRouteTable {
25
+ // Route tree:
26
+ // / -> GET
27
+ // /users -> GET, POST
28
+ // /users/:id -> GET, PUT, DELETE
29
+ // /posts/:id/comments -> GET
30
+ const root: CompiledRoute = {
31
+ segment: "",
32
+ handlers: {
33
+ GET: { ref: "root#index", middleware: [] },
34
+ },
35
+ children: {
36
+ users: {
37
+ segment: "users",
38
+ handlers: {
39
+ GET: { ref: "users#list", middleware: [] },
40
+ POST: { ref: "users#create", middleware: [] },
41
+ },
42
+ paramChild: {
43
+ segment: ":id",
44
+ paramName: "id",
45
+ handlers: {
46
+ GET: { ref: "users#get", middleware: [] },
47
+ PUT: { ref: "users#update", middleware: [] },
48
+ DELETE: { ref: "users#delete", middleware: [] },
49
+ },
50
+ },
51
+ },
52
+ posts: {
53
+ segment: "posts",
54
+ paramChild: {
55
+ segment: ":id",
56
+ paramName: "id",
57
+ children: {
58
+ comments: {
59
+ segment: "comments",
60
+ handlers: {
61
+ GET: { ref: "comments#list", middleware: [] },
62
+ },
63
+ },
64
+ },
65
+ },
66
+ },
67
+ },
68
+ };
69
+ return root;
70
+ }
71
+
72
+ /** Route table with validator references */
73
+ function makeValidatedRouteTable(): CompiledRouteTable {
74
+ const root: CompiledRoute = {
75
+ segment: "",
76
+ children: {
77
+ users: {
78
+ segment: "users",
79
+ handlers: {
80
+ GET: {
81
+ ref: "users#list",
82
+ middleware: [],
83
+ validators: { query: "ListUsersQuery" },
84
+ },
85
+ POST: {
86
+ ref: "users#create",
87
+ middleware: [],
88
+ validators: { body: "CreateUserBody" },
89
+ },
90
+ },
91
+ paramChild: {
92
+ segment: ":id",
93
+ paramName: "id",
94
+ handlers: {
95
+ GET: {
96
+ ref: "users#get",
97
+ middleware: [],
98
+ validators: { params: "UserIdParams" },
99
+ },
100
+ PUT: {
101
+ ref: "users#update",
102
+ middleware: [],
103
+ validators: {
104
+ params: "UserIdParams",
105
+ body: "UpdateUserBody",
106
+ },
107
+ },
108
+ },
109
+ },
110
+ },
111
+ },
112
+ };
113
+ return root;
114
+ }
115
+
116
+ function makeHandlerMap(): HandlerMap {
117
+ return {
118
+ "root#index": async () => ({
119
+ status: 200,
120
+ headers: {},
121
+ body: { message: "Welcome" },
122
+ }),
123
+ "users#list": async (req: TypoKitRequest) => ({
124
+ status: 200,
125
+ headers: {},
126
+ body: { users: [], query: req.query },
127
+ }),
128
+ "users#create": async (req: TypoKitRequest) => ({
129
+ status: 201,
130
+ headers: {},
131
+ body: { created: true, data: req.body },
132
+ }),
133
+ "users#get": async (req: TypoKitRequest) => ({
134
+ status: 200,
135
+ headers: {},
136
+ body: { id: req.params.id },
137
+ }),
138
+ "users#update": async (req: TypoKitRequest) => ({
139
+ status: 200,
140
+ headers: {},
141
+ body: { updated: req.params.id, data: req.body },
142
+ }),
143
+ "users#delete": async (_req: TypoKitRequest) => ({
144
+ status: 204,
145
+ headers: {},
146
+ body: null,
147
+ }),
148
+ "comments#list": async (req: TypoKitRequest) => ({
149
+ status: 200,
150
+ headers: {},
151
+ body: { postId: req.params.id, comments: [] },
152
+ }),
153
+ };
154
+ }
155
+
156
+ function makeValidatorMap(): ValidatorMap {
157
+ return {
158
+ UserIdParams: (input) => {
159
+ const obj = input as Record<string, unknown>;
160
+ const errors = [];
161
+ if (typeof obj.id !== "string" || !/^\d+$/.test(obj.id)) {
162
+ errors.push({ path: "id", expected: "numeric string", actual: obj.id });
163
+ }
164
+ return errors.length === 0
165
+ ? { success: true, data: input }
166
+ : { success: false, errors };
167
+ },
168
+ ListUsersQuery: (input) => {
169
+ const obj = input as Record<string, unknown>;
170
+ const errors = [];
171
+ if (obj.page !== undefined && typeof obj.page !== "string") {
172
+ errors.push({
173
+ path: "page",
174
+ expected: "string",
175
+ actual: typeof obj.page,
176
+ });
177
+ }
178
+ if (obj.limit !== undefined) {
179
+ const limit = Number(obj.limit);
180
+ if (isNaN(limit) || limit < 1 || limit > 100) {
181
+ errors.push({ path: "limit", expected: "1-100", actual: obj.limit });
182
+ }
183
+ }
184
+ return errors.length === 0
185
+ ? { success: true, data: input }
186
+ : { success: false, errors };
187
+ },
188
+ CreateUserBody: (input) => {
189
+ const obj = input as Record<string, unknown>;
190
+ const errors = [];
191
+ if (typeof obj !== "object" || obj === null) {
192
+ return {
193
+ success: false,
194
+ errors: [
195
+ { path: "$input", expected: "object", actual: typeof input },
196
+ ],
197
+ };
198
+ }
199
+ if (typeof obj.name !== "string" || obj.name.length === 0) {
200
+ errors.push({
201
+ path: "name",
202
+ expected: "non-empty string",
203
+ actual: obj.name,
204
+ });
205
+ }
206
+ if (typeof obj.email !== "string" || !obj.email.includes("@")) {
207
+ errors.push({
208
+ path: "email",
209
+ expected: "valid email",
210
+ actual: obj.email,
211
+ });
212
+ }
213
+ return errors.length === 0
214
+ ? { success: true, data: input }
215
+ : { success: false, errors };
216
+ },
217
+ UpdateUserBody: (input) => {
218
+ const obj = input as Record<string, unknown>;
219
+ const errors = [];
220
+ if (typeof obj !== "object" || obj === null) {
221
+ return {
222
+ success: false,
223
+ errors: [
224
+ { path: "$input", expected: "object", actual: typeof input },
225
+ ],
226
+ };
227
+ }
228
+ if (obj.name !== undefined && typeof obj.name !== "string") {
229
+ errors.push({
230
+ path: "name",
231
+ expected: "string",
232
+ actual: typeof obj.name,
233
+ });
234
+ }
235
+ if (
236
+ obj.email !== undefined &&
237
+ (typeof obj.email !== "string" || !obj.email.includes("@"))
238
+ ) {
239
+ errors.push({
240
+ path: "email",
241
+ expected: "valid email",
242
+ actual: obj.email,
243
+ });
244
+ }
245
+ return errors.length === 0
246
+ ? { success: true, data: input }
247
+ : { success: false, errors };
248
+ },
249
+ };
250
+ }
251
+
252
+ const emptyMiddleware: MiddlewareChain = { entries: [] };
253
+
254
+ async function fetchJson(
255
+ port: number,
256
+ path: string,
257
+ options: { method?: string; body?: unknown } = {},
258
+ ): Promise<{ status: number; headers: Record<string, string>; body: unknown }> {
259
+ const method = options.method ?? "GET";
260
+ const headers: Record<string, string> = {};
261
+ let bodyStr: string | undefined;
262
+
263
+ if (options.body !== undefined) {
264
+ headers["content-type"] = "application/json";
265
+ bodyStr = JSON.stringify(options.body);
266
+ }
267
+
268
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
269
+ method,
270
+ headers,
271
+ body: bodyStr,
272
+ });
273
+
274
+ const resHeaders: Record<string, string> = {};
275
+ res.headers.forEach((v, k) => {
276
+ resHeaders[k] = v;
277
+ });
278
+
279
+ let body: unknown;
280
+ const ct = res.headers.get("content-type") ?? "";
281
+ if (ct.includes("application/json")) {
282
+ body = await res.json();
283
+ } else {
284
+ const text = await res.text();
285
+ body = text || null;
286
+ }
287
+
288
+ return { status: res.status, headers: resHeaders, body };
289
+ }
290
+
291
+ // ─── Unit Tests for Validation Helpers ───────────────────────
292
+
293
+ describe("validationErrorResponse", () => {
294
+ it("produces a 400 response with field-level errors", () => {
295
+ const res = validationErrorResponse("Validation failed", [
296
+ { path: "body.name", expected: "string", actual: 42 },
297
+ ]);
298
+ expect(res.status).toBe(400);
299
+ const body = res.body as ErrorResponse;
300
+ expect(body.error.code).toBe("VALIDATION_ERROR");
301
+ expect(body.error.message).toBe("Validation failed");
302
+ const fields = body.error.details?.fields as Array<{ path: string }>;
303
+ expect(fields).toHaveLength(1);
304
+ expect(fields[0].path).toBe("body.name");
305
+ });
306
+ });
307
+
308
+ describe("runValidators", () => {
309
+ it("returns undefined when no validators configured", () => {
310
+ const result = runValidators({ validators: undefined }, null, {}, {}, null);
311
+ expect(result).toBeUndefined();
312
+ });
313
+
314
+ it("returns undefined when no validatorMap provided", () => {
315
+ const result = runValidators(
316
+ { validators: { body: "SomeValidator" } },
317
+ null,
318
+ {},
319
+ {},
320
+ null,
321
+ );
322
+ expect(result).toBeUndefined();
323
+ });
324
+
325
+ it("returns undefined when validator ref not found in map", () => {
326
+ const result = runValidators(
327
+ { validators: { body: "MissingValidator" } },
328
+ {},
329
+ {},
330
+ {},
331
+ null,
332
+ );
333
+ expect(result).toBeUndefined();
334
+ });
335
+
336
+ it("returns undefined when all validators pass", () => {
337
+ const validators: ValidatorMap = {
338
+ BodyValidator: () => ({ success: true, data: {} }),
339
+ };
340
+ const result = runValidators(
341
+ { validators: { body: "BodyValidator" } },
342
+ validators,
343
+ {},
344
+ {},
345
+ { name: "Alice" },
346
+ );
347
+ expect(result).toBeUndefined();
348
+ });
349
+
350
+ it("returns 400 response when body validator fails", () => {
351
+ const validators: ValidatorMap = {
352
+ BodyValidator: () => ({
353
+ success: false,
354
+ errors: [{ path: "name", expected: "string", actual: undefined }],
355
+ }),
356
+ };
357
+ const result = runValidators(
358
+ { validators: { body: "BodyValidator" } },
359
+ validators,
360
+ {},
361
+ {},
362
+ {},
363
+ );
364
+ expect(result).toBeDefined();
365
+ expect(result!.status).toBe(400);
366
+ const body = result!.body as ErrorResponse;
367
+ expect(body.error.code).toBe("VALIDATION_ERROR");
368
+ const fields = body.error.details?.fields as Array<{ path: string }>;
369
+ expect(fields[0].path).toBe("body.name");
370
+ });
371
+
372
+ it("prefixes param errors with params.", () => {
373
+ const validators: ValidatorMap = {
374
+ ParamVal: () => ({
375
+ success: false,
376
+ errors: [{ path: "id", expected: "numeric", actual: "abc" }],
377
+ }),
378
+ };
379
+ const result = runValidators(
380
+ { validators: { params: "ParamVal" } },
381
+ validators,
382
+ { id: "abc" },
383
+ {},
384
+ null,
385
+ );
386
+ expect(result).toBeDefined();
387
+ const fields = (result!.body as ErrorResponse).error.details
388
+ ?.fields as Array<{ path: string }>;
389
+ expect(fields[0].path).toBe("params.id");
390
+ });
391
+
392
+ it("prefixes query errors with query.", () => {
393
+ const validators: ValidatorMap = {
394
+ QueryVal: () => ({
395
+ success: false,
396
+ errors: [{ path: "limit", expected: "number", actual: "abc" }],
397
+ }),
398
+ };
399
+ const result = runValidators(
400
+ { validators: { query: "QueryVal" } },
401
+ validators,
402
+ {},
403
+ { limit: "abc" },
404
+ null,
405
+ );
406
+ expect(result).toBeDefined();
407
+ const fields = (result!.body as ErrorResponse).error.details
408
+ ?.fields as Array<{ path: string }>;
409
+ expect(fields[0].path).toBe("query.limit");
410
+ });
411
+
412
+ it("aggregates errors from multiple validators", () => {
413
+ const validators: ValidatorMap = {
414
+ ParamVal: () => ({
415
+ success: false,
416
+ errors: [{ path: "id", expected: "numeric", actual: "abc" }],
417
+ }),
418
+ BodyVal: () => ({
419
+ success: false,
420
+ errors: [{ path: "name", expected: "string", actual: 42 }],
421
+ }),
422
+ };
423
+ const result = runValidators(
424
+ { validators: { params: "ParamVal", body: "BodyVal" } },
425
+ validators,
426
+ { id: "abc" },
427
+ {},
428
+ { name: 42 },
429
+ );
430
+ expect(result).toBeDefined();
431
+ const fields = (result!.body as ErrorResponse).error.details
432
+ ?.fields as Array<{ path: string }>;
433
+ expect(fields).toHaveLength(2);
434
+ expect(fields[0].path).toBe("params.id");
435
+ expect(fields[1].path).toBe("body.name");
436
+ });
437
+ });
438
+
439
+ // ─── Original Tests ──────────────────────────────────────────
440
+
441
+ describe("nativeServer", () => {
442
+ it("creates a server adapter with correct name", () => {
443
+ const adapter = nativeServer();
444
+ expect(adapter.name).toBe("native");
445
+ });
446
+
447
+ it("implements the ServerAdapter interface", () => {
448
+ const adapter = nativeServer();
449
+ expect(typeof adapter.registerRoutes).toBe("function");
450
+ expect(typeof adapter.listen).toBe("function");
451
+ expect(typeof adapter.normalizeRequest).toBe("function");
452
+ expect(typeof adapter.writeResponse).toBe("function");
453
+ expect(typeof adapter.getNativeServer).toBe("function");
454
+ });
455
+ });
456
+
457
+ describe("nativeServer integration", () => {
458
+ it("routes GET / to root handler", async () => {
459
+ const adapter = nativeServer();
460
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
461
+ const handle = await adapter.listen(0);
462
+ try {
463
+ const server = adapter.getNativeServer!() as Server;
464
+ const addr = server.address() as { port: number };
465
+ const res = await fetchJson(addr.port, "/");
466
+ expect(res.status).toBe(200);
467
+ expect((res.body as Record<string, unknown>).message).toBe("Welcome");
468
+ } finally {
469
+ await handle.close();
470
+ }
471
+ });
472
+
473
+ it("routes GET /users to list handler", async () => {
474
+ const adapter = nativeServer();
475
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
476
+ const handle = await adapter.listen(0);
477
+ try {
478
+ const server = adapter.getNativeServer!() as Server;
479
+ const addr = server.address() as { port: number };
480
+ const res = await fetchJson(addr.port, "/users");
481
+ expect(res.status).toBe(200);
482
+ expect((res.body as Record<string, unknown>).users).toEqual([]);
483
+ } finally {
484
+ await handle.close();
485
+ }
486
+ });
487
+
488
+ it("extracts route params from /users/:id", async () => {
489
+ const adapter = nativeServer();
490
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
491
+ const handle = await adapter.listen(0);
492
+ try {
493
+ const server = adapter.getNativeServer!() as Server;
494
+ const addr = server.address() as { port: number };
495
+ const res = await fetchJson(addr.port, "/users/42");
496
+ expect(res.status).toBe(200);
497
+ expect((res.body as Record<string, unknown>).id).toBe("42");
498
+ } finally {
499
+ await handle.close();
500
+ }
501
+ });
502
+
503
+ it("handles POST /users with body", async () => {
504
+ const adapter = nativeServer();
505
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
506
+ const handle = await adapter.listen(0);
507
+ try {
508
+ const server = adapter.getNativeServer!() as Server;
509
+ const addr = server.address() as { port: number };
510
+ const res = await fetchJson(addr.port, "/users", {
511
+ method: "POST",
512
+ body: { name: "Alice" },
513
+ });
514
+ expect(res.status).toBe(201);
515
+ const b = res.body as Record<string, unknown>;
516
+ expect(b.created).toBe(true);
517
+ expect((b.data as Record<string, unknown>).name).toBe("Alice");
518
+ } finally {
519
+ await handle.close();
520
+ }
521
+ });
522
+
523
+ it("handles nested param routes: /posts/:id/comments", async () => {
524
+ const adapter = nativeServer();
525
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
526
+ const handle = await adapter.listen(0);
527
+ try {
528
+ const server = adapter.getNativeServer!() as Server;
529
+ const addr = server.address() as { port: number };
530
+ const res = await fetchJson(addr.port, "/posts/99/comments");
531
+ expect(res.status).toBe(200);
532
+ const b = res.body as Record<string, unknown>;
533
+ expect(b.postId).toBe("99");
534
+ expect(b.comments).toEqual([]);
535
+ } finally {
536
+ await handle.close();
537
+ }
538
+ });
539
+
540
+ it("returns 404 for unknown routes", async () => {
541
+ const adapter = nativeServer();
542
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
543
+ const handle = await adapter.listen(0);
544
+ try {
545
+ const server = adapter.getNativeServer!() as Server;
546
+ const addr = server.address() as { port: number };
547
+ const res = await fetchJson(addr.port, "/nonexistent");
548
+ expect(res.status).toBe(404);
549
+ expect((res.body as Record<string, unknown>).error).toBe("Not Found");
550
+ } finally {
551
+ await handle.close();
552
+ }
553
+ });
554
+
555
+ it("returns 405 with Allow header for wrong method", async () => {
556
+ const adapter = nativeServer();
557
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
558
+ const handle = await adapter.listen(0);
559
+ try {
560
+ const server = adapter.getNativeServer!() as Server;
561
+ const addr = server.address() as { port: number };
562
+ const res = await fetchJson(addr.port, "/users", { method: "PATCH" });
563
+ expect(res.status).toBe(405);
564
+ expect(res.headers["allow"]).toBeDefined();
565
+ expect(res.headers["allow"]).toContain("GET");
566
+ expect(res.headers["allow"]).toContain("POST");
567
+ } finally {
568
+ await handle.close();
569
+ }
570
+ });
571
+
572
+ it("normalizes trailing slashes: /users/ matches /users", async () => {
573
+ const adapter = nativeServer();
574
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
575
+ const handle = await adapter.listen(0);
576
+ try {
577
+ const server = adapter.getNativeServer!() as Server;
578
+ const addr = server.address() as { port: number };
579
+ const res = await fetchJson(addr.port, "/users/");
580
+ expect(res.status).toBe(200);
581
+ expect((res.body as Record<string, unknown>).users).toEqual([]);
582
+ } finally {
583
+ await handle.close();
584
+ }
585
+ });
586
+
587
+ it("getNativeServer returns the underlying http.Server", async () => {
588
+ const adapter = nativeServer();
589
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
590
+ const handle = await adapter.listen(0);
591
+ try {
592
+ const server = adapter.getNativeServer!();
593
+ expect(server).toBeDefined();
594
+ expect(typeof (server as Record<string, unknown>).listen).toBe(
595
+ "function",
596
+ );
597
+ } finally {
598
+ await handle.close();
599
+ }
600
+ });
601
+
602
+ it("normalizeRequest creates TypoKitRequest from raw object", () => {
603
+ const adapter = nativeServer();
604
+ const raw = {
605
+ method: "GET" as const,
606
+ path: "/test",
607
+ headers: { "x-foo": "bar" },
608
+ body: null,
609
+ query: { q: "hello" },
610
+ params: { id: "1" },
611
+ };
612
+ const req = adapter.normalizeRequest(raw);
613
+ expect(req.method).toBe("GET");
614
+ expect(req.path).toBe("/test");
615
+ expect(req.headers["x-foo"]).toBe("bar");
616
+ expect(req.query.q).toBe("hello");
617
+ expect(req.params.id).toBe("1");
618
+ });
619
+
620
+ it("handles DELETE /users/:id", async () => {
621
+ const adapter = nativeServer();
622
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
623
+ const handle = await adapter.listen(0);
624
+ try {
625
+ const server = adapter.getNativeServer!() as Server;
626
+ const addr = server.address() as { port: number };
627
+ const res = await fetchJson(addr.port, "/users/5", { method: "DELETE" });
628
+ expect(res.status).toBe(204);
629
+ } finally {
630
+ await handle.close();
631
+ }
632
+ });
633
+
634
+ it("handles PUT /users/:id with body", async () => {
635
+ const adapter = nativeServer();
636
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
637
+ const handle = await adapter.listen(0);
638
+ try {
639
+ const server = adapter.getNativeServer!() as Server;
640
+ const addr = server.address() as { port: number };
641
+ const res = await fetchJson(addr.port, "/users/7", {
642
+ method: "PUT",
643
+ body: { name: "Updated" },
644
+ });
645
+ expect(res.status).toBe(200);
646
+ const b = res.body as Record<string, unknown>;
647
+ expect(b.updated).toBe("7");
648
+ expect((b.data as Record<string, unknown>).name).toBe("Updated");
649
+ } finally {
650
+ await handle.close();
651
+ }
652
+ });
653
+ });
654
+
655
+ // ─── Validation Pipeline Integration Tests ───────────────────
656
+
657
+ describe("validation pipeline integration", () => {
658
+ it("valid POST request passes through validators to handler", async () => {
659
+ const adapter = nativeServer();
660
+ adapter.registerRoutes(
661
+ makeValidatedRouteTable(),
662
+ makeHandlerMap(),
663
+ emptyMiddleware,
664
+ makeValidatorMap(),
665
+ );
666
+ const handle = await adapter.listen(0);
667
+ try {
668
+ const server = adapter.getNativeServer!() as Server;
669
+ const addr = server.address() as { port: number };
670
+ const res = await fetchJson(addr.port, "/users", {
671
+ method: "POST",
672
+ body: { name: "Alice", email: "alice@example.com" },
673
+ });
674
+ expect(res.status).toBe(201);
675
+ const b = res.body as Record<string, unknown>;
676
+ expect(b.created).toBe(true);
677
+ expect((b.data as Record<string, unknown>).name).toBe("Alice");
678
+ } finally {
679
+ await handle.close();
680
+ }
681
+ });
682
+
683
+ it("invalid POST body returns 400 with field errors", async () => {
684
+ const adapter = nativeServer();
685
+ adapter.registerRoutes(
686
+ makeValidatedRouteTable(),
687
+ makeHandlerMap(),
688
+ emptyMiddleware,
689
+ makeValidatorMap(),
690
+ );
691
+ const handle = await adapter.listen(0);
692
+ try {
693
+ const server = adapter.getNativeServer!() as Server;
694
+ const addr = server.address() as { port: number };
695
+ const res = await fetchJson(addr.port, "/users", {
696
+ method: "POST",
697
+ body: { name: "", email: "not-an-email" },
698
+ });
699
+ expect(res.status).toBe(400);
700
+ const body = res.body as ErrorResponse;
701
+ expect(body.error.code).toBe("VALIDATION_ERROR");
702
+ expect(body.error.message).toBe("Request validation failed");
703
+ const fields = body.error.details?.fields as Array<{
704
+ path: string;
705
+ expected: string;
706
+ }>;
707
+ expect(fields.length).toBeGreaterThan(0);
708
+ // All body errors should be prefixed with "body."
709
+ for (const f of fields) {
710
+ expect(f.path.startsWith("body.")).toBe(true);
711
+ }
712
+ } finally {
713
+ await handle.close();
714
+ }
715
+ });
716
+
717
+ it("invalid path params return 400 with field errors", async () => {
718
+ const adapter = nativeServer();
719
+ adapter.registerRoutes(
720
+ makeValidatedRouteTable(),
721
+ makeHandlerMap(),
722
+ emptyMiddleware,
723
+ makeValidatorMap(),
724
+ );
725
+ const handle = await adapter.listen(0);
726
+ try {
727
+ const server = adapter.getNativeServer!() as Server;
728
+ const addr = server.address() as { port: number };
729
+ // "abc" is not a numeric string
730
+ const res = await fetchJson(addr.port, "/users/abc");
731
+ expect(res.status).toBe(400);
732
+ const body = res.body as ErrorResponse;
733
+ expect(body.error.code).toBe("VALIDATION_ERROR");
734
+ const fields = body.error.details?.fields as Array<{ path: string }>;
735
+ expect(fields.some((f) => f.path === "params.id")).toBe(true);
736
+ } finally {
737
+ await handle.close();
738
+ }
739
+ });
740
+
741
+ it("valid path params pass through to handler", async () => {
742
+ const adapter = nativeServer();
743
+ adapter.registerRoutes(
744
+ makeValidatedRouteTable(),
745
+ makeHandlerMap(),
746
+ emptyMiddleware,
747
+ makeValidatorMap(),
748
+ );
749
+ const handle = await adapter.listen(0);
750
+ try {
751
+ const server = adapter.getNativeServer!() as Server;
752
+ const addr = server.address() as { port: number };
753
+ const res = await fetchJson(addr.port, "/users/42");
754
+ expect(res.status).toBe(200);
755
+ expect((res.body as Record<string, unknown>).id).toBe("42");
756
+ } finally {
757
+ await handle.close();
758
+ }
759
+ });
760
+
761
+ it("invalid query params return 400 with field errors", async () => {
762
+ const adapter = nativeServer();
763
+ adapter.registerRoutes(
764
+ makeValidatedRouteTable(),
765
+ makeHandlerMap(),
766
+ emptyMiddleware,
767
+ makeValidatorMap(),
768
+ );
769
+ const handle = await adapter.listen(0);
770
+ try {
771
+ const server = adapter.getNativeServer!() as Server;
772
+ const addr = server.address() as { port: number };
773
+ const res = await fetchJson(addr.port, "/users?limit=999");
774
+ expect(res.status).toBe(400);
775
+ const body = res.body as ErrorResponse;
776
+ expect(body.error.code).toBe("VALIDATION_ERROR");
777
+ const fields = body.error.details?.fields as Array<{ path: string }>;
778
+ expect(fields.some((f) => f.path === "query.limit")).toBe(true);
779
+ } finally {
780
+ await handle.close();
781
+ }
782
+ });
783
+
784
+ it("valid query params pass through to handler", async () => {
785
+ const adapter = nativeServer();
786
+ adapter.registerRoutes(
787
+ makeValidatedRouteTable(),
788
+ makeHandlerMap(),
789
+ emptyMiddleware,
790
+ makeValidatorMap(),
791
+ );
792
+ const handle = await adapter.listen(0);
793
+ try {
794
+ const server = adapter.getNativeServer!() as Server;
795
+ const addr = server.address() as { port: number };
796
+ const res = await fetchJson(addr.port, "/users?limit=10&page=1");
797
+ expect(res.status).toBe(200);
798
+ expect((res.body as Record<string, unknown>).users).toEqual([]);
799
+ } finally {
800
+ await handle.close();
801
+ }
802
+ });
803
+
804
+ it("multiple validator failures are aggregated into a single 400 response", async () => {
805
+ const adapter = nativeServer();
806
+ adapter.registerRoutes(
807
+ makeValidatedRouteTable(),
808
+ makeHandlerMap(),
809
+ emptyMiddleware,
810
+ makeValidatorMap(),
811
+ );
812
+ const handle = await adapter.listen(0);
813
+ try {
814
+ const server = adapter.getNativeServer!() as Server;
815
+ const addr = server.address() as { port: number };
816
+ // PUT /users/abc with invalid body — both params and body fail
817
+ const res = await fetchJson(addr.port, "/users/abc", {
818
+ method: "PUT",
819
+ body: { name: 123, email: "bad" },
820
+ });
821
+ expect(res.status).toBe(400);
822
+ const body = res.body as ErrorResponse;
823
+ const fields = body.error.details?.fields as Array<{ path: string }>;
824
+ // Should have params.id error and body field errors
825
+ expect(fields.some((f) => f.path === "params.id")).toBe(true);
826
+ expect(fields.some((f) => f.path.startsWith("body."))).toBe(true);
827
+ expect(fields.length).toBeGreaterThanOrEqual(2);
828
+ } finally {
829
+ await handle.close();
830
+ }
831
+ });
832
+
833
+ it("routes without validators still work normally", async () => {
834
+ const adapter = nativeServer();
835
+ adapter.registerRoutes(
836
+ makeValidatedRouteTable(),
837
+ makeHandlerMap(),
838
+ emptyMiddleware,
839
+ makeValidatorMap(),
840
+ );
841
+ const handle = await adapter.listen(0);
842
+ try {
843
+ const server = adapter.getNativeServer!() as Server;
844
+ const addr = server.address() as { port: number };
845
+ // DELETE is not in the validated route table — use original table
846
+ // Use GET /users with valid query to confirm validators work with no issues
847
+ const res = await fetchJson(addr.port, "/users?page=1");
848
+ expect(res.status).toBe(200);
849
+ } finally {
850
+ await handle.close();
851
+ }
852
+ });
853
+
854
+ it("works without validatorMap (backwards compatible)", async () => {
855
+ const adapter = nativeServer();
856
+ // Register without validatorMap — no validators run
857
+ adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
858
+ const handle = await adapter.listen(0);
859
+ try {
860
+ const server = adapter.getNativeServer!() as Server;
861
+ const addr = server.address() as { port: number };
862
+ const res = await fetchJson(addr.port, "/users");
863
+ expect(res.status).toBe(200);
864
+ } finally {
865
+ await handle.close();
866
+ }
867
+ });
868
+ });
869
+
870
+ // ─── Response Serialization Tests ────────────────────────────
871
+
872
+ /** Route table with serializer references */
873
+ function makeSerializedRouteTable(): CompiledRouteTable {
874
+ const root: CompiledRoute = {
875
+ segment: "",
876
+ handlers: {
877
+ GET: { ref: "root#index", middleware: [], serializer: "RootResponse" },
878
+ },
879
+ children: {
880
+ users: {
881
+ segment: "users",
882
+ handlers: {
883
+ GET: {
884
+ ref: "users#list",
885
+ middleware: [],
886
+ serializer: "UserListResponse",
887
+ },
888
+ POST: { ref: "users#create", middleware: [] }, // no serializer — fallback
889
+ },
890
+ paramChild: {
891
+ segment: ":id",
892
+ paramName: "id",
893
+ handlers: {
894
+ GET: {
895
+ ref: "users#get",
896
+ middleware: [],
897
+ serializer: "UserResponse",
898
+ },
899
+ DELETE: { ref: "users#delete", middleware: [] },
900
+ },
901
+ },
902
+ },
903
+ nested: {
904
+ segment: "nested",
905
+ handlers: {
906
+ GET: {
907
+ ref: "nested#get",
908
+ middleware: [],
909
+ serializer: "NestedResponse",
910
+ },
911
+ },
912
+ },
913
+ types: {
914
+ segment: "types",
915
+ handlers: {
916
+ GET: {
917
+ ref: "types#all",
918
+ middleware: [],
919
+ serializer: "AllTypesResponse",
920
+ },
921
+ },
922
+ },
923
+ "no-schema": {
924
+ segment: "no-schema",
925
+ handlers: {
926
+ GET: {
927
+ ref: "noschema#get",
928
+ middleware: [],
929
+ serializer: "MissingSerializer",
930
+ },
931
+ },
932
+ },
933
+ },
934
+ };
935
+ return root;
936
+ }
937
+
938
+ function makeSerializerHandlerMap(): HandlerMap {
939
+ return {
940
+ "root#index": async () => ({
941
+ status: 200,
942
+ headers: {},
943
+ body: { message: "Welcome" },
944
+ }),
945
+ "users#list": async () => ({
946
+ status: 200,
947
+ headers: {},
948
+ body: {
949
+ users: [
950
+ { id: "1", name: "Alice" },
951
+ { id: "2", name: "Bob" },
952
+ ],
953
+ },
954
+ }),
955
+ "users#create": async (req: TypoKitRequest) => ({
956
+ status: 201,
957
+ headers: {},
958
+ body: { created: true, data: req.body },
959
+ }),
960
+ "users#get": async (req: TypoKitRequest) => ({
961
+ status: 200,
962
+ headers: {},
963
+ body: { id: req.params.id, name: "User " + req.params.id },
964
+ }),
965
+ "users#delete": async () => ({
966
+ status: 204,
967
+ headers: {},
968
+ body: null,
969
+ }),
970
+ "nested#get": async () => ({
971
+ status: 200,
972
+ headers: {},
973
+ body: {
974
+ data: { items: [{ a: 1, b: [true, false] }], meta: { total: 1 } },
975
+ },
976
+ }),
977
+ "types#all": async () => ({
978
+ status: 200,
979
+ headers: {},
980
+ body: {
981
+ str: "hello",
982
+ num: 42,
983
+ bool: true,
984
+ nil: null,
985
+ arr: [1, 2, 3],
986
+ obj: { k: "v" },
987
+ },
988
+ }),
989
+ "noschema#get": async () => ({
990
+ status: 200,
991
+ headers: {},
992
+ body: { fallback: true },
993
+ }),
994
+ };
995
+ }
996
+
997
+ function makeSerializerMap(): SerializerMap {
998
+ // Simulates compiled fast-json-stringify schemas — produces JSON strings
999
+ return {
1000
+ RootResponse: (input) => JSON.stringify(input),
1001
+ UserListResponse: (input) => {
1002
+ // Custom serializer that produces equivalent JSON but proves it was called
1003
+ const obj = input as Record<string, unknown>;
1004
+ const users = obj.users as Array<Record<string, string>>;
1005
+ return `{"users":[${users.map((u) => `{"id":"${u.id}","name":"${u.name}"}`).join(",")}]}`;
1006
+ },
1007
+ UserResponse: (input) => {
1008
+ const obj = input as Record<string, unknown>;
1009
+ return `{"id":"${obj.id}","name":"${obj.name}"}`;
1010
+ },
1011
+ NestedResponse: (input) => JSON.stringify(input),
1012
+ AllTypesResponse: (input) => JSON.stringify(input),
1013
+ // MissingSerializer is deliberately NOT here to test fallback
1014
+ };
1015
+ }
1016
+
1017
+ describe("serializeResponse (unit)", () => {
1018
+ it("returns response unchanged for null body", () => {
1019
+ const res = serializeResponse(
1020
+ { status: 204, headers: {}, body: null },
1021
+ "Ref",
1022
+ null,
1023
+ );
1024
+ expect(res.body).toBeNull();
1025
+ });
1026
+
1027
+ it("returns response unchanged for undefined body", () => {
1028
+ const res = serializeResponse(
1029
+ { status: 204, headers: {}, body: undefined },
1030
+ "Ref",
1031
+ null,
1032
+ );
1033
+ expect(res.body).toBeUndefined();
1034
+ });
1035
+
1036
+ it("returns response unchanged for string body", () => {
1037
+ const res = serializeResponse(
1038
+ { status: 200, headers: {}, body: "plain text" },
1039
+ "Ref",
1040
+ null,
1041
+ );
1042
+ expect(res.body).toBe("plain text");
1043
+ });
1044
+
1045
+ it("uses compiled serializer when available", () => {
1046
+ const serializers: SerializerMap = {
1047
+ TestRef: () => '{"fast":true}',
1048
+ };
1049
+ const res = serializeResponse(
1050
+ { status: 200, headers: {}, body: { fast: true } },
1051
+ "TestRef",
1052
+ serializers,
1053
+ );
1054
+ expect(res.body).toBe('{"fast":true}');
1055
+ expect(res.headers["content-type"]).toBe("application/json");
1056
+ });
1057
+
1058
+ it("falls back to JSON.stringify when no serializer ref", () => {
1059
+ const res = serializeResponse(
1060
+ { status: 200, headers: {}, body: { a: 1 } },
1061
+ undefined,
1062
+ null,
1063
+ );
1064
+ expect(res.body).toBe('{"a":1}');
1065
+ expect(res.headers["content-type"]).toBe("application/json");
1066
+ });
1067
+
1068
+ it("falls back to JSON.stringify when serializer ref not in map", () => {
1069
+ const res = serializeResponse(
1070
+ { status: 200, headers: {}, body: { b: 2 } },
1071
+ "Missing",
1072
+ {},
1073
+ );
1074
+ expect(res.body).toBe('{"b":2}');
1075
+ expect(res.headers["content-type"]).toBe("application/json");
1076
+ });
1077
+
1078
+ it("does not overwrite existing content-type header", () => {
1079
+ const res = serializeResponse(
1080
+ {
1081
+ status: 200,
1082
+ headers: { "content-type": "application/vnd.api+json" },
1083
+ body: { x: 1 },
1084
+ },
1085
+ undefined,
1086
+ null,
1087
+ );
1088
+ expect(res.headers["content-type"]).toBe("application/vnd.api+json");
1089
+ });
1090
+
1091
+ it("serializes all JSON types correctly", () => {
1092
+ const body = {
1093
+ str: "hello",
1094
+ num: 42,
1095
+ bool: true,
1096
+ nil: null,
1097
+ arr: [1, 2],
1098
+ obj: { k: "v" },
1099
+ };
1100
+ const res = serializeResponse(
1101
+ { status: 200, headers: {}, body },
1102
+ undefined,
1103
+ null,
1104
+ );
1105
+ const parsed = JSON.parse(res.body as string);
1106
+ expect(parsed.str).toBe("hello");
1107
+ expect(parsed.num).toBe(42);
1108
+ expect(parsed.bool).toBe(true);
1109
+ expect(parsed.nil).toBeNull();
1110
+ expect(parsed.arr).toEqual([1, 2]);
1111
+ expect(parsed.obj).toEqual({ k: "v" });
1112
+ });
1113
+ });
1114
+
1115
+ describe("response serialization integration", () => {
1116
+ it("serializes response body using compiled serializer", async () => {
1117
+ const adapter = nativeServer();
1118
+ adapter.registerRoutes(
1119
+ makeSerializedRouteTable(),
1120
+ makeSerializerHandlerMap(),
1121
+ emptyMiddleware,
1122
+ undefined,
1123
+ makeSerializerMap(),
1124
+ );
1125
+ const handle = await adapter.listen(0);
1126
+ try {
1127
+ const server = adapter.getNativeServer!() as Server;
1128
+ const addr = server.address() as { port: number };
1129
+ const res = await fetchJson(addr.port, "/users");
1130
+ expect(res.status).toBe(200);
1131
+ const b = res.body as Record<string, unknown>;
1132
+ expect(b.users).toEqual([
1133
+ { id: "1", name: "Alice" },
1134
+ { id: "2", name: "Bob" },
1135
+ ]);
1136
+ } finally {
1137
+ await handle.close();
1138
+ }
1139
+ });
1140
+
1141
+ it("sets content-type to application/json automatically", async () => {
1142
+ const adapter = nativeServer();
1143
+ adapter.registerRoutes(
1144
+ makeSerializedRouteTable(),
1145
+ makeSerializerHandlerMap(),
1146
+ emptyMiddleware,
1147
+ undefined,
1148
+ makeSerializerMap(),
1149
+ );
1150
+ const handle = await adapter.listen(0);
1151
+ try {
1152
+ const server = adapter.getNativeServer!() as Server;
1153
+ const addr = server.address() as { port: number };
1154
+ const res = await fetchJson(addr.port, "/");
1155
+ expect(res.status).toBe(200);
1156
+ expect(res.headers["content-type"]).toContain("application/json");
1157
+ } finally {
1158
+ await handle.close();
1159
+ }
1160
+ });
1161
+
1162
+ it("falls back to JSON.stringify when no compiled schema exists", async () => {
1163
+ const adapter = nativeServer();
1164
+ adapter.registerRoutes(
1165
+ makeSerializedRouteTable(),
1166
+ makeSerializerHandlerMap(),
1167
+ emptyMiddleware,
1168
+ undefined,
1169
+ makeSerializerMap(),
1170
+ );
1171
+ const handle = await adapter.listen(0);
1172
+ try {
1173
+ const server = adapter.getNativeServer!() as Server;
1174
+ const addr = server.address() as { port: number };
1175
+ // POST /users has no serializer ref — uses fallback
1176
+ const res = await fetchJson(addr.port, "/users", {
1177
+ method: "POST",
1178
+ body: { name: "Test" },
1179
+ });
1180
+ expect(res.status).toBe(201);
1181
+ const b = res.body as Record<string, unknown>;
1182
+ expect(b.created).toBe(true);
1183
+ expect(res.headers["content-type"]).toContain("application/json");
1184
+ } finally {
1185
+ await handle.close();
1186
+ }
1187
+ });
1188
+
1189
+ it("falls back when serializer ref points to missing serializer in map", async () => {
1190
+ const adapter = nativeServer();
1191
+ adapter.registerRoutes(
1192
+ makeSerializedRouteTable(),
1193
+ makeSerializerHandlerMap(),
1194
+ emptyMiddleware,
1195
+ undefined,
1196
+ makeSerializerMap(),
1197
+ );
1198
+ const handle = await adapter.listen(0);
1199
+ try {
1200
+ const server = adapter.getNativeServer!() as Server;
1201
+ const addr = server.address() as { port: number };
1202
+ // GET /no-schema has serializer: "MissingSerializer" which is not in the map
1203
+ const res = await fetchJson(addr.port, "/no-schema");
1204
+ expect(res.status).toBe(200);
1205
+ const b = res.body as Record<string, unknown>;
1206
+ expect(b.fallback).toBe(true);
1207
+ expect(res.headers["content-type"]).toContain("application/json");
1208
+ } finally {
1209
+ await handle.close();
1210
+ }
1211
+ });
1212
+
1213
+ it("handles nested objects correctly with serializer", async () => {
1214
+ const adapter = nativeServer();
1215
+ adapter.registerRoutes(
1216
+ makeSerializedRouteTable(),
1217
+ makeSerializerHandlerMap(),
1218
+ emptyMiddleware,
1219
+ undefined,
1220
+ makeSerializerMap(),
1221
+ );
1222
+ const handle = await adapter.listen(0);
1223
+ try {
1224
+ const server = adapter.getNativeServer!() as Server;
1225
+ const addr = server.address() as { port: number };
1226
+ const res = await fetchJson(addr.port, "/nested");
1227
+ expect(res.status).toBe(200);
1228
+ const b = res.body as Record<string, unknown>;
1229
+ const data = b.data as Record<string, unknown>;
1230
+ expect(data.items).toEqual([{ a: 1, b: [true, false] }]);
1231
+ expect(data.meta).toEqual({ total: 1 });
1232
+ } finally {
1233
+ await handle.close();
1234
+ }
1235
+ });
1236
+
1237
+ it("handles all JSON types (strings, numbers, booleans, nulls, arrays, objects)", async () => {
1238
+ const adapter = nativeServer();
1239
+ adapter.registerRoutes(
1240
+ makeSerializedRouteTable(),
1241
+ makeSerializerHandlerMap(),
1242
+ emptyMiddleware,
1243
+ undefined,
1244
+ makeSerializerMap(),
1245
+ );
1246
+ const handle = await adapter.listen(0);
1247
+ try {
1248
+ const server = adapter.getNativeServer!() as Server;
1249
+ const addr = server.address() as { port: number };
1250
+ const res = await fetchJson(addr.port, "/types");
1251
+ expect(res.status).toBe(200);
1252
+ const b = res.body as Record<string, unknown>;
1253
+ expect(b.str).toBe("hello");
1254
+ expect(b.num).toBe(42);
1255
+ expect(b.bool).toBe(true);
1256
+ expect(b.nil).toBeNull();
1257
+ expect(b.arr).toEqual([1, 2, 3]);
1258
+ expect(b.obj).toEqual({ k: "v" });
1259
+ } finally {
1260
+ await handle.close();
1261
+ }
1262
+ });
1263
+
1264
+ it("does not serialize null body (e.g., 204 responses)", async () => {
1265
+ const adapter = nativeServer();
1266
+ adapter.registerRoutes(
1267
+ makeSerializedRouteTable(),
1268
+ makeSerializerHandlerMap(),
1269
+ emptyMiddleware,
1270
+ undefined,
1271
+ makeSerializerMap(),
1272
+ );
1273
+ const handle = await adapter.listen(0);
1274
+ try {
1275
+ const server = adapter.getNativeServer!() as Server;
1276
+ const addr = server.address() as { port: number };
1277
+ const res = await fetchJson(addr.port, "/users/5", { method: "DELETE" });
1278
+ expect(res.status).toBe(204);
1279
+ } finally {
1280
+ await handle.close();
1281
+ }
1282
+ });
1283
+
1284
+ it("works without serializerMap (backwards compatible)", async () => {
1285
+ const adapter = nativeServer();
1286
+ adapter.registerRoutes(
1287
+ makeSerializedRouteTable(),
1288
+ makeSerializerHandlerMap(),
1289
+ emptyMiddleware,
1290
+ );
1291
+ const handle = await adapter.listen(0);
1292
+ try {
1293
+ const server = adapter.getNativeServer!() as Server;
1294
+ const addr = server.address() as { port: number };
1295
+ const res = await fetchJson(addr.port, "/");
1296
+ expect(res.status).toBe(200);
1297
+ expect((res.body as Record<string, unknown>).message).toBe("Welcome");
1298
+ expect(res.headers["content-type"]).toContain("application/json");
1299
+ } finally {
1300
+ await handle.close();
1301
+ }
1302
+ });
1303
+ });