@dotpress/testing 0.0.0-stage → 2.0.0-alpha.0

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.js ADDED
@@ -0,0 +1,1813 @@
1
+ // src/assertions.ts
2
+ import { expect } from "vitest";
3
+ var ExpectationError = class extends Error {
4
+ name = "AssertionError";
5
+ };
6
+ var matches = (actual, expected) => {
7
+ try {
8
+ if (typeof expected === "object" && expected !== null && typeof actual === "object" && actual !== null) {
9
+ expect(actual).toMatchObject(expected);
10
+ } else {
11
+ expect(actual).toEqual(expected);
12
+ }
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ };
18
+ var show = (value) => {
19
+ let json2;
20
+ try {
21
+ json2 = JSON.stringify(
22
+ value,
23
+ (_key, item) => typeof item === "object" && item !== null && "asymmetricMatch" in item ? item.toAsymmetricMatcher?.() ?? String(item) : item
24
+ );
25
+ } catch {
26
+ json2 = void 0;
27
+ }
28
+ json2 ??= String(value);
29
+ return json2.length > 300 ? `${json2.slice(0, 297)}...` : json2;
30
+ };
31
+ var checkTimes = (label, times) => {
32
+ if (times !== void 0 && (!Number.isInteger(times) || times < 0)) {
33
+ throw new TypeError(`${label}: times must be a non-negative integer.`);
34
+ }
35
+ };
36
+ var assertCount = (description, found, times, recorded) => {
37
+ if (times === void 0 ? found > 0 : found === times) return;
38
+ const expected = times === void 0 ? description.some : times === 0 ? description.none : `${description.some} ${times === 1 ? "once" : `${times} times`}`;
39
+ throw new ExpectationError(
40
+ `Expected ${expected}, found ${found}.
41
+ ${recorded}`
42
+ );
43
+ };
44
+
45
+ // src/conformance.ts
46
+ import {
47
+ DEFAULT_BODY_LIMIT,
48
+ createRouter,
49
+ file,
50
+ json,
51
+ notFoundError,
52
+ redirect,
53
+ sse,
54
+ stream,
55
+ text,
56
+ unauthorizedError
57
+ } from "@dotpress/core";
58
+ import { describe, expect as expect2, it, vi } from "vitest";
59
+
60
+ // src/logger.ts
61
+ var createMemoryLogger = () => {
62
+ const entries = [];
63
+ const create = () => ({
64
+ debug: (...args) => void entries.push({ level: "debug", args }),
65
+ info: (...args) => void entries.push({ level: "info", args }),
66
+ warn: (...args) => void entries.push({ level: "warn", args }),
67
+ error: (...args) => void entries.push({ level: "error", args }),
68
+ child: () => create()
69
+ });
70
+ return Object.assign(create(), { entries });
71
+ };
72
+
73
+ // src/request.ts
74
+ var toRequest = async (path, init) => {
75
+ const request = new Request(new URL(path, "http://localhost"), init);
76
+ if (!(init?.body instanceof FormData)) return request;
77
+ return new Request(request, { body: await request.arrayBuffer() });
78
+ };
79
+
80
+ // src/conformance.ts
81
+ var createHttpTarget = (baseUrl, close) => ({
82
+ fetch: (path, init) => fetch(new URL(path, baseUrl), init),
83
+ close
84
+ });
85
+ var createFetchTarget = (handle, close) => ({
86
+ fetch: async (path, init) => handle(await toRequest(path, init)),
87
+ close
88
+ });
89
+ var TIMEOUT = 2e3;
90
+ var within = (promise, label) => Promise.race([
91
+ promise,
92
+ new Promise(
93
+ (_, reject) => setTimeout(
94
+ () => reject(new Error(`Timed out waiting for: ${label}`)),
95
+ TIMEOUT
96
+ )
97
+ )
98
+ ]);
99
+ var deferred = () => {
100
+ let resolve;
101
+ const promise = new Promise((done) => {
102
+ resolve = done;
103
+ });
104
+ return { promise, resolve };
105
+ };
106
+ var waitForAbort = (signal) => new Promise((resolve) => {
107
+ if (signal.aborted) resolve();
108
+ signal.addEventListener("abort", () => resolve(), { once: true });
109
+ });
110
+ var readChunk = async (reader) => {
111
+ const { done, value } = await reader.read();
112
+ return done ? void 0 : new TextDecoder().decode(value);
113
+ };
114
+ var runConformanceSuite = (options) => {
115
+ const serve = async (define, appOptions = {}) => {
116
+ const router = createRouter();
117
+ define(router);
118
+ return options.setup({
119
+ logger: createMemoryLogger(),
120
+ ...appOptions,
121
+ routers: [router]
122
+ });
123
+ };
124
+ const withApp = async (define, run, appOptions) => {
125
+ const target = await serve(define, appOptions);
126
+ try {
127
+ await run(target);
128
+ } finally {
129
+ await target.close();
130
+ }
131
+ };
132
+ const postJson = (body) => ({
133
+ method: "POST",
134
+ headers: { "content-type": "application/json" },
135
+ body: JSON.stringify(body)
136
+ });
137
+ describe(`${options.name} adapter conformance`, () => {
138
+ describe("routing", () => {
139
+ it("serves JSON results", () => withApp(
140
+ (router) => {
141
+ router.defineRoute({ path: "/hello", handler: () => ({ hi: 1 }) });
142
+ },
143
+ async ({ fetch: fetch2 }) => {
144
+ const res = await fetch2("/hello");
145
+ expect2(res.status).toBe(200);
146
+ expect2(res.headers.get("content-type")).toBe(
147
+ "application/json; charset=utf-8"
148
+ );
149
+ expect2(await res.json()).toEqual({ hi: 1 });
150
+ }
151
+ ));
152
+ it("routes every HTTP method", () => withApp(
153
+ (router) => {
154
+ for (const method of [
155
+ "get",
156
+ "post",
157
+ "put",
158
+ "patch",
159
+ "delete",
160
+ "options"
161
+ ]) {
162
+ router.defineRoute({
163
+ method,
164
+ path: "/resource",
165
+ handler: ({ req }) => ({ method: req.method })
166
+ });
167
+ }
168
+ router.defineRoute({
169
+ method: "head",
170
+ path: "/resource",
171
+ handler: ({ res }) => {
172
+ res.setHeader("x-head", "yes");
173
+ }
174
+ });
175
+ },
176
+ async ({ fetch: fetch2 }) => {
177
+ for (const method of [
178
+ "GET",
179
+ "POST",
180
+ "PUT",
181
+ "PATCH",
182
+ "DELETE",
183
+ "OPTIONS"
184
+ ]) {
185
+ const res = await fetch2("/resource", { method });
186
+ expect2(await res.json()).toEqual({ method });
187
+ }
188
+ const head = await fetch2("/resource", { method: "HEAD" });
189
+ expect2(head.headers.get("x-head")).toBe("yes");
190
+ }
191
+ ));
192
+ it("extracts decoded path parameters", () => withApp(
193
+ (router) => {
194
+ router.defineRoute({
195
+ path: "/orgs/:orgId/files/:name",
196
+ handler: ({ req }) => req.params
197
+ });
198
+ },
199
+ async ({ fetch: fetch2 }) => {
200
+ const res = await fetch2("/orgs/acme/files/a%20b%2Fc%C3%A9");
201
+ expect2(await res.json()).toEqual({ orgId: "acme", name: "a b/c\xE9" });
202
+ }
203
+ ));
204
+ it("gives static segments priority over parameters", () => withApp(
205
+ (router) => {
206
+ router.defineRoute({
207
+ path: "/users/:id",
208
+ handler: ({ req }) => ({ id: req.params.id })
209
+ });
210
+ router.defineRoute({ path: "/users/me", handler: () => "me" });
211
+ },
212
+ async ({ fetch: fetch2 }) => {
213
+ expect2(await (await fetch2("/users/me")).json()).toBe("me");
214
+ expect2(await (await fetch2("/users/42")).json()).toEqual({
215
+ id: "42"
216
+ });
217
+ }
218
+ ));
219
+ it("ignores trailing slashes and matches case-sensitively", () => withApp(
220
+ (router) => {
221
+ router.defineRoute({ path: "/users", handler: () => "users" });
222
+ },
223
+ async ({ fetch: fetch2 }) => {
224
+ expect2((await fetch2("/users/")).status).toBe(200);
225
+ expect2((await fetch2("/Users")).status).toBe(404);
226
+ }
227
+ ));
228
+ it("returns the standard 404 error for unknown paths", () => withApp(
229
+ (router) => {
230
+ router.defineRoute({ path: "/items", handler: () => [] });
231
+ },
232
+ async ({ fetch: fetch2 }) => {
233
+ for (const method of ["GET", "POST"]) {
234
+ const res = await fetch2("/missing", { method });
235
+ expect2(res.status).toBe(404);
236
+ expect2(res.headers.get("x-request-id")).toBeTruthy();
237
+ expect2(await res.json()).toEqual({
238
+ status: 404,
239
+ error: "NOT_FOUND",
240
+ message: "Route not found"
241
+ });
242
+ }
243
+ }
244
+ ));
245
+ it("returns 405 with an Allow header for unsupported methods", () => withApp(
246
+ (router) => {
247
+ router.defineRoute({ path: "/items/:id", handler: () => ({}) });
248
+ router.defineRoute({
249
+ method: "delete",
250
+ path: "/items/:id",
251
+ handler: () => {
252
+ }
253
+ });
254
+ },
255
+ async ({ fetch: fetch2 }) => {
256
+ for (const method of ["POST", "OPTIONS"]) {
257
+ const res = await fetch2("/items/1", { method });
258
+ expect2(res.status).toBe(405);
259
+ expect2(res.headers.get("allow")).toBe("GET, HEAD, DELETE");
260
+ expect2(res.headers.get("x-request-id")).toBeTruthy();
261
+ expect2(await res.json()).toEqual({
262
+ status: 405,
263
+ error: "METHOD_NOT_ALLOWED",
264
+ message: "Method not allowed"
265
+ });
266
+ }
267
+ }
268
+ ));
269
+ it("answers HEAD requests with the GET route, without body", () => withApp(
270
+ (router) => {
271
+ router.defineRoute({ path: "/data", handler: () => ({ a: 1 }) });
272
+ router.defineRoute({
273
+ path: "/stream",
274
+ handler: () => stream(["never sent"])
275
+ });
276
+ },
277
+ async ({ fetch: fetch2 }) => {
278
+ const res = await fetch2("/data", { method: "HEAD" });
279
+ expect2(res.status).toBe(200);
280
+ expect2(res.headers.get("content-type")).toBe(
281
+ "application/json; charset=utf-8"
282
+ );
283
+ expect2(await res.text()).toBe("");
284
+ const streamed = await fetch2("/stream", { method: "HEAD" });
285
+ expect2(streamed.status).toBe(200);
286
+ expect2(await streamed.text()).toBe("");
287
+ }
288
+ ));
289
+ it("mounts groups with their prefix and middlewares", () => {
290
+ const calls = [];
291
+ const track = (name) => () => {
292
+ calls.push(name);
293
+ };
294
+ return withApp(
295
+ (router) => {
296
+ const api = router.createGroup("/api", [track("api")]);
297
+ api.createGroup("/admin", [track("admin")]).defineRoute({
298
+ path: "/stats",
299
+ middlewares: [track("route")],
300
+ handler: () => ({ ok: true })
301
+ });
302
+ },
303
+ async ({ fetch: fetch2 }) => {
304
+ expect2(await (await fetch2("/api/admin/stats")).json()).toEqual({
305
+ ok: true
306
+ });
307
+ expect2(calls).toEqual(["global", "api", "admin", "route"]);
308
+ },
309
+ { middlewares: [track("global")] }
310
+ );
311
+ });
312
+ it("serves the routes under the base path only", () => withApp(
313
+ (router) => {
314
+ router.defineRoute({
315
+ path: "/users/:id",
316
+ handler: ({ req }) => ({ id: req.params.id, path: req.path })
317
+ });
318
+ },
319
+ async ({ fetch: fetch2 }) => {
320
+ const res = await fetch2("/my-app/api/users/42");
321
+ expect2(res.status).toBe(200);
322
+ expect2(await res.json()).toEqual({
323
+ id: "42",
324
+ path: "/my-app/api/users/42"
325
+ });
326
+ for (const path of ["/users/42", "/my-app/users/42"]) {
327
+ const outside = await fetch2(path);
328
+ expect2(outside.status, path).toBe(404);
329
+ expect2(await outside.json()).toMatchObject({
330
+ error: "NOT_FOUND"
331
+ });
332
+ }
333
+ },
334
+ { basePath: "/my-app/api" }
335
+ ));
336
+ });
337
+ describe("request", () => {
338
+ it("parses the query string", () => withApp(
339
+ (router) => {
340
+ router.defineRoute({
341
+ path: "/search",
342
+ handler: ({ req }) => req.query
343
+ });
344
+ },
345
+ async ({ fetch: fetch2 }) => {
346
+ const res = await fetch2(
347
+ "/search?q=hello+world&tag=a&tag=b&name=%C3%A9&empty="
348
+ );
349
+ expect2(await res.json()).toEqual({
350
+ q: "hello world",
351
+ tag: ["a", "b"],
352
+ name: "\xE9",
353
+ empty: ""
354
+ });
355
+ }
356
+ ));
357
+ it("exposes the method, path, URL, headers and cookies", () => withApp(
358
+ (router) => {
359
+ router.defineRoute({
360
+ method: "post",
361
+ path: "/inspect",
362
+ handler: ({ req }) => ({
363
+ method: req.method,
364
+ path: req.path,
365
+ url: req.url && new URL(req.url).pathname + new URL(req.url).search,
366
+ custom: req.header("X-Custom"),
367
+ lowercase: req.headers["x-custom"],
368
+ cookies: req.cookies
369
+ })
370
+ });
371
+ },
372
+ async ({ fetch: fetch2 }) => {
373
+ const res = await fetch2("/inspect?a=1", {
374
+ method: "POST",
375
+ headers: {
376
+ "X-Custom": "value",
377
+ cookie: "session=abc; theme=dark%20blue"
378
+ }
379
+ });
380
+ expect2(await res.json()).toEqual({
381
+ method: "POST",
382
+ path: "/inspect",
383
+ url: "/inspect?a=1",
384
+ custom: "value",
385
+ lowercase: "value",
386
+ cookies: { session: "abc", theme: "dark blue" }
387
+ });
388
+ }
389
+ ));
390
+ it("exposes the client IP address", () => withApp(
391
+ (router) => {
392
+ router.defineRoute({
393
+ path: "/ip",
394
+ handler: ({ req }) => ({ ip: req.ip })
395
+ });
396
+ },
397
+ async ({ fetch: fetch2 }) => {
398
+ const { ip } = await (await fetch2("/ip")).json();
399
+ expect2(ip).toMatch(/^((::ffff:)?127\.0\.0\.1|::1)$/);
400
+ }
401
+ ));
402
+ it("parses JSON bodies", () => withApp(
403
+ (router) => {
404
+ router.defineRoute({
405
+ method: "post",
406
+ path: "/echo",
407
+ handler: ({ req }) => ({ body: req.body })
408
+ });
409
+ },
410
+ async ({ fetch: fetch2 }) => {
411
+ const res = await fetch2("/echo", postJson({ a: [1, "\xE9"], b: null }));
412
+ expect2(await res.json()).toEqual({ body: { a: [1, "\xE9"], b: null } });
413
+ }
414
+ ));
415
+ it("rejects malformed JSON bodies", () => withApp(
416
+ (router) => {
417
+ router.defineRoute({
418
+ method: "post",
419
+ path: "/echo",
420
+ handler: () => ({ unreachable: true })
421
+ });
422
+ },
423
+ async ({ fetch: fetch2 }) => {
424
+ const res = await fetch2("/echo", {
425
+ method: "POST",
426
+ headers: { "content-type": "application/json" },
427
+ body: '{"broken":'
428
+ });
429
+ expect2(res.status).toBe(400);
430
+ expect2(res.headers.get("x-request-id")).toBeTruthy();
431
+ expect2(await res.json()).toEqual({
432
+ status: 400,
433
+ error: "MALFORMED_BODY",
434
+ message: "Malformed request body"
435
+ });
436
+ }
437
+ ));
438
+ it("parses URL-encoded, text and binary bodies", () => withApp(
439
+ (router) => {
440
+ router.defineRoute({
441
+ method: "post",
442
+ path: "/echo",
443
+ handler: ({ req }) => req.body instanceof Uint8Array ? { bytes: [...req.body] } : { body: req.body }
444
+ });
445
+ },
446
+ async ({ fetch: fetch2 }) => {
447
+ const form = await fetch2("/echo", {
448
+ method: "POST",
449
+ headers: { "content-type": "application/x-www-form-urlencoded" },
450
+ body: "name=Ada+Lovelace&tag=a&tag=b"
451
+ });
452
+ expect2(await form.json()).toEqual({
453
+ body: { name: "Ada Lovelace", tag: ["a", "b"] }
454
+ });
455
+ const plain = await fetch2("/echo", {
456
+ method: "POST",
457
+ headers: { "content-type": "text/plain; charset=utf-8" },
458
+ body: "h\xE9llo"
459
+ });
460
+ expect2(await plain.json()).toEqual({ body: "h\xE9llo" });
461
+ const binary = await fetch2("/echo", {
462
+ method: "POST",
463
+ headers: { "content-type": "application/octet-stream" },
464
+ body: new Uint8Array([0, 1, 254, 255])
465
+ });
466
+ expect2(await binary.json()).toEqual({ bytes: [0, 1, 254, 255] });
467
+ }
468
+ ));
469
+ it("exposes the raw body on rawBody routes", () => withApp(
470
+ (router) => {
471
+ router.defineRoute({
472
+ method: "post",
473
+ path: "/webhooks",
474
+ rawBody: true,
475
+ schema: (z) => ({ body: z.object({ type: z.string() }) }),
476
+ handler: ({ req }) => ({
477
+ raw: new TextDecoder().decode(req.rawBody),
478
+ type: req.body.type
479
+ })
480
+ });
481
+ router.defineRoute({
482
+ method: "post",
483
+ path: "/ping",
484
+ rawBody: true,
485
+ handler: ({ req }) => ({ length: req.rawBody.byteLength })
486
+ });
487
+ },
488
+ async ({ fetch: fetch2 }) => {
489
+ const payload = '{ "type" : "invoice.paid", "id": "\xE9" }';
490
+ const res = await fetch2("/webhooks", {
491
+ method: "POST",
492
+ headers: { "content-type": "application/json" },
493
+ body: payload
494
+ });
495
+ expect2(await res.json()).toEqual({
496
+ raw: payload,
497
+ type: "invoice.paid"
498
+ });
499
+ const empty = await fetch2("/ping", { method: "POST" });
500
+ expect2(await empty.json()).toEqual({ length: 0 });
501
+ }
502
+ ));
503
+ it("gives an undefined body when there is none", () => withApp(
504
+ (router) => {
505
+ router.defineRoute({
506
+ method: "post",
507
+ path: "/empty",
508
+ handler: ({ req }) => ({ hasBody: req.body !== void 0 })
509
+ });
510
+ },
511
+ async ({ fetch: fetch2 }) => {
512
+ const res = await fetch2("/empty", { method: "POST" });
513
+ expect2(await res.json()).toEqual({ hasBody: false });
514
+ }
515
+ ));
516
+ it("rejects bodies larger than the default limit", () => withApp(
517
+ (router) => {
518
+ router.defineRoute({
519
+ method: "post",
520
+ path: "/upload",
521
+ handler: () => ({ unreachable: true })
522
+ });
523
+ },
524
+ async ({ fetch: fetch2 }) => {
525
+ const payload = new Uint8Array(DEFAULT_BODY_LIMIT + 1);
526
+ const declared = await fetch2("/upload", {
527
+ method: "POST",
528
+ headers: { "content-type": "application/octet-stream" },
529
+ body: payload
530
+ });
531
+ expect2(declared.status).toBe(413);
532
+ expect2(await declared.json()).toEqual({
533
+ status: 413,
534
+ error: "PAYLOAD_TOO_LARGE",
535
+ message: "Request body too large"
536
+ });
537
+ const chunked = await fetch2("/upload", {
538
+ method: "POST",
539
+ headers: { "content-type": "application/octet-stream" },
540
+ body: new ReadableStream({
541
+ start(controller) {
542
+ controller.enqueue(payload.subarray(0, DEFAULT_BODY_LIMIT));
543
+ controller.enqueue(new Uint8Array(1));
544
+ controller.close();
545
+ }
546
+ }),
547
+ duplex: "half"
548
+ });
549
+ expect2(chunked.status).toBe(413);
550
+ }
551
+ ));
552
+ it("accepts bodies up to the default limit", () => withApp(
553
+ (router) => {
554
+ router.defineRoute({
555
+ method: "post",
556
+ path: "/upload",
557
+ handler: ({ req }) => ({
558
+ size: req.body.byteLength
559
+ })
560
+ });
561
+ },
562
+ async ({ fetch: fetch2 }) => {
563
+ const res = await fetch2("/upload", {
564
+ method: "POST",
565
+ headers: { "content-type": "application/octet-stream" },
566
+ body: new Uint8Array(DEFAULT_BODY_LIMIT)
567
+ });
568
+ expect2(await res.json()).toEqual({ size: DEFAULT_BODY_LIMIT });
569
+ }
570
+ ));
571
+ it("rejects compressed request bodies", () => withApp(
572
+ (router) => {
573
+ router.defineRoute({
574
+ method: "post",
575
+ path: "/echo",
576
+ handler: () => ({ unreachable: true })
577
+ });
578
+ },
579
+ async ({ fetch: fetch2 }) => {
580
+ const res = await fetch2("/echo", {
581
+ method: "POST",
582
+ headers: {
583
+ "content-type": "application/json",
584
+ "content-encoding": "gzip"
585
+ },
586
+ body: new Uint8Array([31, 139, 8, 0])
587
+ });
588
+ expect2(res.status).toBe(415);
589
+ expect2(await res.json()).toMatchObject({
590
+ error: "UNSUPPORTED_MEDIA_TYPE"
591
+ });
592
+ }
593
+ ));
594
+ });
595
+ describe("uploads", () => {
596
+ const formWith = (entries) => {
597
+ const form = new FormData();
598
+ for (const [name, value] of entries) form.append(name, value);
599
+ return form;
600
+ };
601
+ it("parses multipart bodies into fields and files", () => {
602
+ const bytes = Uint8Array.from({ length: 256 }, (_, index) => index);
603
+ return withApp(
604
+ (router) => {
605
+ router.defineRoute({
606
+ method: "post",
607
+ path: "/documents",
608
+ schema: (z) => ({
609
+ body: z.object({ title: z.string() }),
610
+ files: z.object({
611
+ document: z.file().mime(["application/pdf"])
612
+ })
613
+ }),
614
+ handler: async ({ req }) => ({
615
+ title: req.body.title,
616
+ name: req.files.document.name,
617
+ type: req.files.document.type,
618
+ bytes: [
619
+ ...new Uint8Array(await req.files.document.arrayBuffer())
620
+ ]
621
+ })
622
+ });
623
+ router.defineRoute({
624
+ method: "post",
625
+ path: "/gallery",
626
+ handler: ({ req }) => ({
627
+ fields: req.body,
628
+ files: Object.fromEntries(
629
+ Object.entries(req.files).map(([name, value]) => [
630
+ name,
631
+ [value].flat().map((file2) => file2.name)
632
+ ])
633
+ )
634
+ })
635
+ });
636
+ },
637
+ async ({ fetch: fetch2 }) => {
638
+ const res = await fetch2("/documents", {
639
+ method: "POST",
640
+ body: formWith([
641
+ ["title", "Contrat sign\xE9"],
642
+ [
643
+ "document",
644
+ new File([bytes], "contrat \xE9t\xE9.pdf", {
645
+ type: "application/pdf"
646
+ })
647
+ ]
648
+ ])
649
+ });
650
+ expect2(await res.json()).toEqual({
651
+ title: "Contrat sign\xE9",
652
+ name: "contrat \xE9t\xE9.pdf",
653
+ type: "application/pdf",
654
+ bytes: [...bytes]
655
+ });
656
+ const gallery = await fetch2("/gallery", {
657
+ method: "POST",
658
+ body: formWith([
659
+ ["tag", "sea"],
660
+ ["tag", "sun"],
661
+ ["photos", new File(["a"], "a.jpg")],
662
+ ["photos", new File(["b"], "b.jpg")],
663
+ ["cover", new File(["c"], "c.jpg")]
664
+ ])
665
+ });
666
+ expect2(await gallery.json()).toEqual({
667
+ fields: { tag: ["sea", "sun"] },
668
+ files: { photos: ["a.jpg", "b.jpg"], cover: ["c.jpg"] }
669
+ });
670
+ }
671
+ );
672
+ });
673
+ it("validates files", () => withApp(
674
+ (router) => {
675
+ router.defineRoute({
676
+ method: "post",
677
+ path: "/avatar",
678
+ schema: (z) => ({
679
+ files: z.object({
680
+ avatar: z.file().max(10).mime(["image/png"])
681
+ })
682
+ }),
683
+ handler: () => ({ ok: true })
684
+ });
685
+ },
686
+ async ({ fetch: fetch2 }) => {
687
+ const res = await fetch2("/avatar", {
688
+ method: "POST",
689
+ body: formWith([
690
+ [
691
+ "avatar",
692
+ new File([new Uint8Array(11)], "a.gif", {
693
+ type: "image/gif"
694
+ })
695
+ ]
696
+ ])
697
+ });
698
+ expect2(res.status).toBe(400);
699
+ expect2(await res.json()).toMatchObject({
700
+ error: "VALIDATION_FAILED",
701
+ data: {
702
+ issues: [
703
+ { source: "files", path: ["avatar"], code: "too_big" },
704
+ { source: "files", path: ["avatar"], code: "invalid_value" }
705
+ ]
706
+ }
707
+ });
708
+ }
709
+ ));
710
+ it("applies the route body limit", () => withApp(
711
+ (router) => {
712
+ router.defineRoute({
713
+ method: "post",
714
+ path: "/large",
715
+ bodyLimit: 3 * DEFAULT_BODY_LIMIT,
716
+ handler: ({ req }) => ({
717
+ size: req.files.file.size
718
+ })
719
+ });
720
+ router.defineRoute({
721
+ method: "post",
722
+ path: "/small",
723
+ bodyLimit: 100,
724
+ handler: () => ({ unreachable: true })
725
+ });
726
+ },
727
+ async ({ fetch: fetch2 }) => {
728
+ const size = 2 * DEFAULT_BODY_LIMIT;
729
+ const large = await fetch2("/large", {
730
+ method: "POST",
731
+ body: formWith([
732
+ ["file", new File([new Uint8Array(size)], "big.bin")]
733
+ ])
734
+ });
735
+ expect2(await large.json()).toEqual({ size });
736
+ const small = await fetch2("/small", {
737
+ method: "POST",
738
+ body: formWith([
739
+ ["file", new File([new Uint8Array(200)], "a.bin")]
740
+ ])
741
+ });
742
+ expect2(small.status).toBe(413);
743
+ }
744
+ ));
745
+ it("rejects malformed multipart bodies", () => withApp(
746
+ (router) => {
747
+ router.defineRoute({
748
+ method: "post",
749
+ path: "/upload",
750
+ handler: () => ({ unreachable: true })
751
+ });
752
+ },
753
+ async ({ fetch: fetch2 }) => {
754
+ const res = await fetch2("/upload", {
755
+ method: "POST",
756
+ headers: { "content-type": "multipart/form-data; boundary=xyz" },
757
+ body: "not a multipart body"
758
+ });
759
+ expect2(res.status).toBe(400);
760
+ expect2(await res.json()).toMatchObject({ error: "MALFORMED_BODY" });
761
+ }
762
+ ));
763
+ });
764
+ describe("validation", () => {
765
+ it("returns VALIDATION_FAILED errors and parsed values", () => withApp(
766
+ (router) => {
767
+ router.defineRoute({
768
+ method: "post",
769
+ path: "/users/:id",
770
+ schema: (z) => ({
771
+ params: z.object({ id: z.coerce.number().int() }),
772
+ query: z.object({ notify: z.enum(["yes", "no"]) }),
773
+ body: z.object({ name: z.string().trim() })
774
+ }),
775
+ handler: ({ req }) => ({
776
+ id: req.params.id,
777
+ notify: req.query.notify,
778
+ name: req.body.name
779
+ })
780
+ });
781
+ },
782
+ async ({ fetch: fetch2 }) => {
783
+ const invalid = await fetch2("/users/abc?notify=maybe", postJson({}));
784
+ expect2(invalid.status).toBe(400);
785
+ expect2(await invalid.json()).toMatchObject({
786
+ status: 400,
787
+ error: "VALIDATION_FAILED",
788
+ data: {
789
+ issues: [
790
+ { source: "params", path: ["id"] },
791
+ { source: "query", path: ["notify"] },
792
+ { source: "body", path: ["name"] }
793
+ ]
794
+ }
795
+ });
796
+ const valid = await fetch2(
797
+ "/users/42?notify=yes",
798
+ postJson({ name: " Ada " })
799
+ );
800
+ expect2(await valid.json()).toEqual({
801
+ id: 42,
802
+ notify: "yes",
803
+ name: "Ada"
804
+ });
805
+ }
806
+ ));
807
+ });
808
+ describe("errors", () => {
809
+ it("serializes returned and thrown HTTP errors", () => withApp(
810
+ (router) => {
811
+ router.defineRoute({
812
+ path: "/returned",
813
+ handler: () => notFoundError("User not found", { id: "1" })
814
+ });
815
+ router.defineRoute({
816
+ path: "/thrown",
817
+ handler: ({ res }) => {
818
+ res.setHeader("www-authenticate", "Bearer");
819
+ throw unauthorizedError();
820
+ }
821
+ });
822
+ },
823
+ async ({ fetch: fetch2 }) => {
824
+ const returned = await fetch2("/returned");
825
+ expect2(returned.status).toBe(404);
826
+ expect2(await returned.json()).toEqual({
827
+ status: 404,
828
+ error: "NOT_FOUND",
829
+ message: "User not found",
830
+ data: { id: "1" }
831
+ });
832
+ const thrown = await fetch2("/thrown");
833
+ expect2(thrown.status).toBe(401);
834
+ expect2(thrown.headers.get("www-authenticate")).toBe("Bearer");
835
+ expect2(await thrown.json()).toEqual({
836
+ status: 401,
837
+ error: "UNAUTHORIZED",
838
+ message: "Unauthorized"
839
+ });
840
+ }
841
+ ));
842
+ it("hides unexpected errors behind a 500 error and reports them", async () => {
843
+ const onError = vi.fn();
844
+ await withApp(
845
+ (router) => {
846
+ router.defineRoute({
847
+ path: "/crash",
848
+ handler: () => {
849
+ throw new Error("Database password is hunter2");
850
+ }
851
+ });
852
+ },
853
+ async ({ fetch: fetch2 }) => {
854
+ const res = await fetch2("/crash");
855
+ expect2(res.status).toBe(500);
856
+ expect2(res.headers.get("x-request-id")).toBeTruthy();
857
+ expect2(await res.json()).toEqual({
858
+ status: 500,
859
+ error: "INTERNAL_ERROR",
860
+ message: "Internal Server Error"
861
+ });
862
+ expect2(onError).toHaveBeenCalledOnce();
863
+ },
864
+ { onError }
865
+ );
866
+ });
867
+ it("exposes error details when exposeErrors is enabled", () => withApp(
868
+ (router) => {
869
+ router.defineRoute({
870
+ path: "/crash",
871
+ handler: () => {
872
+ throw new Error("Boom");
873
+ }
874
+ });
875
+ },
876
+ async ({ fetch: fetch2 }) => {
877
+ expect2(await (await fetch2("/crash")).json()).toMatchObject({
878
+ status: 500,
879
+ data: { name: "Error", message: "Boom" }
880
+ });
881
+ },
882
+ { exposeErrors: true }
883
+ ));
884
+ it("returns a 500 error when the response does not match its schema", () => withApp(
885
+ (router) => {
886
+ router.defineRoute({
887
+ path: "/invalid",
888
+ schema: (z) => ({ response: z.object({ id: z.string() }) }),
889
+ // @ts-expect-error the result does not match the schema
890
+ handler: () => ({ id: 1 })
891
+ });
892
+ },
893
+ async ({ fetch: fetch2 }) => {
894
+ expect2((await fetch2("/invalid")).status).toBe(500);
895
+ }
896
+ ));
897
+ });
898
+ describe("middlewares and filters", () => {
899
+ it("stops the pipeline when a middleware returns a response", () => withApp(
900
+ (router) => {
901
+ router.defineRoute({
902
+ path: "/private",
903
+ middlewares: [
904
+ ({ req }) => req.header("authorization") ? void 0 : unauthorizedError()
905
+ ],
906
+ handler: () => ({ secret: true })
907
+ });
908
+ router.defineRoute({
909
+ path: "/legacy",
910
+ middlewares: [() => redirect("/modern", 301)],
911
+ handler: () => ({ unreachable: true })
912
+ });
913
+ },
914
+ async ({ fetch: fetch2 }) => {
915
+ expect2((await fetch2("/private")).status).toBe(401);
916
+ expect2(
917
+ await (await fetch2("/private", { headers: { authorization: "x" } })).json()
918
+ ).toEqual({ secret: true });
919
+ const legacy = await fetch2("/legacy", { redirect: "manual" });
920
+ expect2(legacy.status).toBe(301);
921
+ expect2(legacy.headers.get("location")).toBe("/modern");
922
+ }
923
+ ));
924
+ it("applies app and router response filters", () => withApp(
925
+ (router) => {
926
+ router.addResponseFilter((_ctx, result) => ({
927
+ ...result,
928
+ router: true
929
+ }));
930
+ router.defineRoute({ path: "/data", handler: () => ({ a: 1 }) });
931
+ },
932
+ async ({ fetch: fetch2 }) => {
933
+ expect2(await (await fetch2("/data")).json()).toEqual({
934
+ a: 1,
935
+ app: true,
936
+ router: true
937
+ });
938
+ },
939
+ {
940
+ responseFilters: [
941
+ (_ctx, result) => ({ ...result, app: true })
942
+ ]
943
+ }
944
+ ));
945
+ });
946
+ describe("responses", () => {
947
+ it("applies statuses, headers and cookies", () => withApp(
948
+ (router) => {
949
+ router.defineRoute({
950
+ method: "post",
951
+ path: "/created",
952
+ status: 201,
953
+ handler: ({ res }) => {
954
+ res.setHeader("location", "/items/1");
955
+ res.setCookie("session", "abc", { httpOnly: true });
956
+ res.setCookie("theme", "dark");
957
+ return { id: 1 };
958
+ }
959
+ });
960
+ router.defineRoute({
961
+ path: "/accepted",
962
+ handler: ({ res }) => {
963
+ res.setStatus(202);
964
+ return json({ queued: true }, { headers: { "x-queue": "a" } });
965
+ }
966
+ });
967
+ },
968
+ async ({ fetch: fetch2 }) => {
969
+ const created = await fetch2("/created", { method: "POST" });
970
+ expect2(created.status).toBe(201);
971
+ expect2(created.headers.get("location")).toBe("/items/1");
972
+ expect2(created.headers.getSetCookie()).toEqual([
973
+ "session=abc; Path=/; HttpOnly",
974
+ "theme=dark; Path=/"
975
+ ]);
976
+ const accepted = await fetch2("/accepted");
977
+ expect2(accepted.status).toBe(202);
978
+ expect2(accepted.headers.get("x-queue")).toBe("a");
979
+ expect2(await accepted.json()).toEqual({ queued: true });
980
+ }
981
+ ));
982
+ it("answers 204 without body when the handler returns nothing", () => withApp(
983
+ (router) => {
984
+ router.defineRoute({
985
+ method: "delete",
986
+ path: "/items/:id",
987
+ handler: () => {
988
+ }
989
+ });
990
+ },
991
+ async ({ fetch: fetch2 }) => {
992
+ const res = await fetch2("/items/1", { method: "DELETE" });
993
+ expect2(res.status).toBe(204);
994
+ expect2(res.headers.get("content-type")).toBeNull();
995
+ expect2(await res.text()).toBe("");
996
+ }
997
+ ));
998
+ it("does not add framework headers", () => withApp(
999
+ (router) => {
1000
+ router.defineRoute({ path: "/plain", handler: () => "ok" });
1001
+ },
1002
+ async ({ fetch: fetch2 }) => {
1003
+ const res = await fetch2("/plain");
1004
+ expect2(res.headers.get("x-powered-by")).toBeNull();
1005
+ expect2(res.headers.get("etag")).toBeNull();
1006
+ }
1007
+ ));
1008
+ it("sends redirects", () => withApp(
1009
+ (router) => {
1010
+ router.defineRoute({
1011
+ path: "/old",
1012
+ handler: () => redirect("/new")
1013
+ });
1014
+ },
1015
+ async ({ fetch: fetch2 }) => {
1016
+ const res = await fetch2("/old", { redirect: "manual" });
1017
+ expect2(res.status).toBe(302);
1018
+ expect2(res.headers.get("location")).toBe("/new");
1019
+ }
1020
+ ));
1021
+ it("sends text responses", () => withApp(
1022
+ (router) => {
1023
+ router.defineRoute({
1024
+ path: "/page",
1025
+ handler: () => text("<h1>H\xE9llo</h1>", {
1026
+ headers: { "content-type": "text/html; charset=utf-8" }
1027
+ })
1028
+ });
1029
+ router.defineRoute({ path: "/health", handler: () => text("OK") });
1030
+ },
1031
+ async ({ fetch: fetch2 }) => {
1032
+ const page = await fetch2("/page");
1033
+ expect2(page.headers.get("content-type")).toBe(
1034
+ "text/html; charset=utf-8"
1035
+ );
1036
+ expect2(await page.text()).toBe("<h1>H\xE9llo</h1>");
1037
+ const health = await fetch2("/health");
1038
+ expect2(health.headers.get("content-type")).toBe(
1039
+ "text/plain; charset=utf-8"
1040
+ );
1041
+ expect2(await health.text()).toBe("OK");
1042
+ }
1043
+ ));
1044
+ it("sends files without altering their bytes", () => {
1045
+ const bytes = Uint8Array.from({ length: 256 }, (_, index) => index);
1046
+ return withApp(
1047
+ (router) => {
1048
+ router.defineRoute({
1049
+ path: "/download",
1050
+ handler: () => file(bytes, {
1051
+ filename: "rapport \xE9t\xE9.bin",
1052
+ contentType: "application/octet-stream"
1053
+ })
1054
+ });
1055
+ router.defineRoute({
1056
+ path: "/blob",
1057
+ handler: () => file(new Blob([bytes], { type: "image/png" }), {
1058
+ disposition: "inline"
1059
+ })
1060
+ });
1061
+ },
1062
+ async ({ fetch: fetch2 }) => {
1063
+ const res = await fetch2("/download");
1064
+ expect2(res.headers.get("content-length")).toBe("256");
1065
+ expect2(res.headers.get("content-disposition")).toBe(
1066
+ `attachment; filename="rapport _t_.bin"; filename*=UTF-8''rapport%20%C3%A9t%C3%A9.bin`
1067
+ );
1068
+ expect2(new Uint8Array(await res.arrayBuffer())).toEqual(bytes);
1069
+ const blob = await fetch2("/blob");
1070
+ expect2(blob.headers.get("content-type")).toBe("image/png");
1071
+ expect2(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes);
1072
+ }
1073
+ );
1074
+ });
1075
+ });
1076
+ describe("cors", () => {
1077
+ const APP = "https://app.example.com";
1078
+ const EVIL = "https://evil.example.org";
1079
+ const corsRoutes = (router) => {
1080
+ router.defineRoute({ path: "/users", handler: () => [] });
1081
+ router.defineRoute({
1082
+ method: "post",
1083
+ path: "/users",
1084
+ handler: () => ({ created: true })
1085
+ });
1086
+ router.defineRoute({
1087
+ path: "/private",
1088
+ middlewares: [() => unauthorizedError()],
1089
+ handler: () => ({ secret: true })
1090
+ });
1091
+ router.defineRoute({
1092
+ method: "options",
1093
+ path: "/custom",
1094
+ handler: () => ({ custom: true })
1095
+ });
1096
+ router.defineRoute({
1097
+ path: "/export.csv",
1098
+ handler: () => stream(["a,b\n"], { contentType: "text/csv" })
1099
+ });
1100
+ };
1101
+ const preflight = (origin = APP) => ({
1102
+ method: "OPTIONS",
1103
+ headers: {
1104
+ origin,
1105
+ "access-control-request-method": "POST",
1106
+ "access-control-request-headers": "content-type, authorization"
1107
+ }
1108
+ });
1109
+ it("answers preflight requests before the middlewares", () => withApp(
1110
+ corsRoutes,
1111
+ async ({ fetch: fetch2 }) => {
1112
+ const res = await fetch2("/users", preflight());
1113
+ expect2(res.status).toBe(204);
1114
+ expect2(res.headers.get("access-control-allow-origin")).toBe(APP);
1115
+ expect2(res.headers.get("access-control-allow-methods")).toBe(
1116
+ "GET, HEAD, POST"
1117
+ );
1118
+ expect2(res.headers.get("access-control-allow-headers")).toBe(
1119
+ "content-type, authorization"
1120
+ );
1121
+ expect2(res.headers.get("access-control-max-age")).toBe("600");
1122
+ expect2(res.headers.get("vary")).toContain("Origin");
1123
+ expect2(await res.text()).toBe("");
1124
+ const private_ = await fetch2("/private", preflight());
1125
+ expect2(private_.status).toBe(204);
1126
+ const custom = await fetch2("/custom", preflight());
1127
+ expect2(custom.status).toBe(204);
1128
+ expect2(custom.headers.get("access-control-allow-methods")).toBe(
1129
+ "OPTIONS"
1130
+ );
1131
+ const refused = await fetch2("/users", preflight(EVIL));
1132
+ expect2(refused.status).toBe(405);
1133
+ expect2(
1134
+ refused.headers.get("access-control-allow-origin")
1135
+ ).toBeNull();
1136
+ expect2((await fetch2("/missing", preflight())).status).toBe(404);
1137
+ },
1138
+ { cors: { origin: [APP], maxAge: 600 } }
1139
+ ));
1140
+ it("adds the CORS headers to every response, errors included", () => withApp(
1141
+ corsRoutes,
1142
+ async ({ fetch: fetch2 }) => {
1143
+ const requests = [
1144
+ ["/users", {}, 200],
1145
+ ["/private", {}, 401],
1146
+ ["/missing", {}, 404],
1147
+ ["/users", { method: "DELETE" }, 405],
1148
+ [
1149
+ "/users",
1150
+ {
1151
+ method: "POST",
1152
+ headers: { "content-type": "application/json" },
1153
+ body: "{oops"
1154
+ },
1155
+ 400
1156
+ ],
1157
+ ["/export.csv", {}, 200]
1158
+ ];
1159
+ for (const [path, init, status] of requests) {
1160
+ const res = await fetch2(path, {
1161
+ ...init,
1162
+ headers: { ...init.headers, origin: APP }
1163
+ });
1164
+ expect2(res.status).toBe(status);
1165
+ expect2(res.headers.get("access-control-allow-origin")).toBe(APP);
1166
+ expect2(res.headers.get("access-control-allow-credentials")).toBe(
1167
+ "true"
1168
+ );
1169
+ expect2(res.headers.get("access-control-expose-headers")).toBe(
1170
+ "x-request-id"
1171
+ );
1172
+ expect2(res.headers.get("vary")).toContain("Origin");
1173
+ await res.arrayBuffer();
1174
+ }
1175
+ const refused = await fetch2("/users", { headers: { origin: EVIL } });
1176
+ expect2(refused.status).toBe(200);
1177
+ expect2(
1178
+ refused.headers.get("access-control-allow-origin")
1179
+ ).toBeNull();
1180
+ expect2(refused.headers.get("vary")).toContain("Origin");
1181
+ },
1182
+ {
1183
+ cors: {
1184
+ origin: APP,
1185
+ credentials: true,
1186
+ exposedHeaders: ["x-request-id"]
1187
+ }
1188
+ }
1189
+ ));
1190
+ });
1191
+ describe("streaming", () => {
1192
+ const incremental = options.bufferedResponses ? it.skip : it;
1193
+ it("streams chunks", () => withApp(
1194
+ (router) => {
1195
+ router.defineRoute({
1196
+ path: "/export.csv",
1197
+ handler: () => stream(
1198
+ async function* () {
1199
+ yield "id,name\n";
1200
+ yield new TextEncoder().encode("1,Ada\n");
1201
+ },
1202
+ { contentType: "text/csv" }
1203
+ )
1204
+ });
1205
+ },
1206
+ async ({ fetch: fetch2 }) => {
1207
+ const res = await fetch2("/export.csv");
1208
+ expect2(res.status).toBe(200);
1209
+ expect2(res.headers.get("content-type")).toBe("text/csv");
1210
+ expect2(await res.text()).toBe("id,name\n1,Ada\n");
1211
+ }
1212
+ ));
1213
+ incremental("flushes Server-Sent Events as they are produced", () => {
1214
+ const released = deferred();
1215
+ return withApp(
1216
+ (router) => {
1217
+ router.defineRoute({
1218
+ path: "/events",
1219
+ handler: () => sse(
1220
+ async function* ({ signal }) {
1221
+ try {
1222
+ yield { event: "ready", data: { ok: true } };
1223
+ await waitForAbort(signal);
1224
+ } finally {
1225
+ released.resolve();
1226
+ }
1227
+ },
1228
+ { heartbeat: false }
1229
+ )
1230
+ });
1231
+ },
1232
+ async ({ fetch: fetch2 }) => {
1233
+ const res = await fetch2("/events");
1234
+ expect2(res.headers.get("content-type")).toBe(
1235
+ "text/event-stream; charset=utf-8"
1236
+ );
1237
+ expect2(res.headers.get("cache-control")).toBe("no-cache");
1238
+ const reader = res.body.getReader();
1239
+ expect2(await within(readChunk(reader), "first event")).toBe(
1240
+ 'event: ready\ndata: {"ok":true}\n\n'
1241
+ );
1242
+ await reader.cancel();
1243
+ await within(released.promise, "source release on disconnect");
1244
+ }
1245
+ );
1246
+ });
1247
+ incremental(
1248
+ "cancels the response stream when the client disconnects",
1249
+ () => {
1250
+ const cancelled = deferred();
1251
+ return withApp(
1252
+ (router) => {
1253
+ router.defineRoute({
1254
+ path: "/download",
1255
+ handler: () => file(
1256
+ new ReadableStream({
1257
+ pull: (controller) => controller.enqueue(new Uint8Array(64 * 1024)),
1258
+ cancel: () => cancelled.resolve()
1259
+ })
1260
+ )
1261
+ });
1262
+ },
1263
+ async ({ fetch: fetch2 }) => {
1264
+ const res = await fetch2("/download");
1265
+ const reader = res.body.getReader();
1266
+ await reader.read();
1267
+ await reader.cancel();
1268
+ await within(cancelled.promise, "response stream cancel");
1269
+ }
1270
+ );
1271
+ }
1272
+ );
1273
+ incremental("aborts req.signal when the client disconnects", () => {
1274
+ const started = deferred();
1275
+ const aborted = deferred();
1276
+ return withApp(
1277
+ (router) => {
1278
+ router.defineRoute({
1279
+ path: "/slow",
1280
+ handler: async ({ req }) => {
1281
+ started.resolve();
1282
+ await waitForAbort(req.signal);
1283
+ aborted.resolve();
1284
+ return { late: true };
1285
+ }
1286
+ });
1287
+ },
1288
+ async ({ fetch: fetch2 }) => {
1289
+ const controller = new AbortController();
1290
+ const pending = fetch2("/slow", { signal: controller.signal }).catch(
1291
+ () => void 0
1292
+ );
1293
+ await within(started.promise, "handler start");
1294
+ controller.abort();
1295
+ await within(aborted.promise, "req.signal abort");
1296
+ await pending;
1297
+ }
1298
+ );
1299
+ });
1300
+ incremental(
1301
+ "aborts the connection when a stream fails",
1302
+ () => withApp(
1303
+ (router) => {
1304
+ router.defineRoute({
1305
+ path: "/broken",
1306
+ handler: () => stream(async function* () {
1307
+ yield "partial";
1308
+ throw new Error("Source failed");
1309
+ })
1310
+ });
1311
+ },
1312
+ async ({ fetch: fetch2 }) => {
1313
+ const res = await fetch2("/broken");
1314
+ expect2(res.status).toBe(200);
1315
+ const outcome = await within(
1316
+ res.text().then(
1317
+ () => "completed",
1318
+ () => "aborted"
1319
+ ),
1320
+ "body read"
1321
+ );
1322
+ expect2(outcome).toBe("aborted");
1323
+ }
1324
+ )
1325
+ );
1326
+ });
1327
+ });
1328
+ };
1329
+
1330
+ // src/lambda.ts
1331
+ var CHUNK = 32768;
1332
+ var encodeBase64 = (bytes) => {
1333
+ let binary = "";
1334
+ for (let index = 0; index < bytes.length; index += CHUNK) {
1335
+ binary += String.fromCharCode(...bytes.subarray(index, index + CHUNK));
1336
+ }
1337
+ return btoa(binary);
1338
+ };
1339
+ var decodeBase64 = (text2) => Uint8Array.from(atob(text2), (char) => char.charCodeAt(0));
1340
+ var TEXT_TYPES = /^\s*(text\/|application\/(json|x-www-form-urlencoded)\b|[^;]*\+json\b)/i;
1341
+ var eventBody = (bytes, contentType) => {
1342
+ if (bytes.byteLength === 0) return { body: null, isBase64Encoded: false };
1343
+ if (contentType && TEXT_TYPES.test(contentType)) {
1344
+ try {
1345
+ const body = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1346
+ return { body, isBase64Encoded: false };
1347
+ } catch {
1348
+ }
1349
+ }
1350
+ return { body: encodeBase64(bytes), isBase64Encoded: true };
1351
+ };
1352
+ var rawQuery = (search) => {
1353
+ const result = {};
1354
+ for (const pair of search.slice(1).split("&")) {
1355
+ if (!pair) continue;
1356
+ const index = pair.indexOf("=");
1357
+ const key = index === -1 ? pair : pair.slice(0, index);
1358
+ const value = index === -1 ? "" : pair.slice(index + 1);
1359
+ (result[key] ??= []).push(value);
1360
+ }
1361
+ return result;
1362
+ };
1363
+ var lastValues = (values) => Object.fromEntries(
1364
+ Object.entries(values).map(([key, list]) => [key, list.at(-1)])
1365
+ );
1366
+ var ACCOUNT = "123456789012";
1367
+ var toLambdaEvent = async (request, format, sourceIp = "127.0.0.1") => {
1368
+ const url = new URL(request.url);
1369
+ const headers = { host: url.host };
1370
+ for (const [name, value] of request.headers) headers[name] = value;
1371
+ const { body, isBase64Encoded } = eventBody(
1372
+ new Uint8Array(await request.arrayBuffer()),
1373
+ request.headers.get("content-type")
1374
+ );
1375
+ const requestId = crypto.randomUUID();
1376
+ const userAgent = headers["user-agent"] ?? "";
1377
+ if (format === "v2") {
1378
+ const { cookie, ...rest } = headers;
1379
+ return {
1380
+ version: "2.0",
1381
+ routeKey: "$default",
1382
+ rawPath: url.pathname,
1383
+ rawQueryString: url.search.slice(1),
1384
+ ...cookie ? { cookies: cookie.split(/;\s*/) } : {},
1385
+ headers: rest,
1386
+ requestContext: {
1387
+ accountId: ACCOUNT,
1388
+ apiId: "api",
1389
+ domainName: url.host,
1390
+ domainPrefix: url.hostname.split(".")[0],
1391
+ http: {
1392
+ method: request.method,
1393
+ path: url.pathname,
1394
+ protocol: "HTTP/1.1",
1395
+ sourceIp,
1396
+ userAgent
1397
+ },
1398
+ requestId,
1399
+ routeKey: "$default",
1400
+ stage: "$default",
1401
+ time: (/* @__PURE__ */ new Date()).toISOString(),
1402
+ timeEpoch: Date.now()
1403
+ },
1404
+ ...body === null ? {} : { body },
1405
+ isBase64Encoded
1406
+ };
1407
+ }
1408
+ const query = {};
1409
+ for (const [key, value] of url.searchParams) (query[key] ??= []).push(value);
1410
+ const multiValueHeaders = Object.fromEntries(
1411
+ Object.entries(headers).map(([name, value]) => [name, [value]])
1412
+ );
1413
+ if (format === "alb") {
1414
+ return {
1415
+ requestContext: {
1416
+ elb: {
1417
+ targetGroupArn: `arn:aws:elasticloadbalancing:us-east-1:${ACCOUNT}:targetgroup/dotpress/0123456789abcdef`
1418
+ }
1419
+ },
1420
+ httpMethod: request.method,
1421
+ path: url.pathname,
1422
+ multiValueQueryStringParameters: rawQuery(url.search),
1423
+ // The load balancer appends the client address.
1424
+ multiValueHeaders: {
1425
+ ...multiValueHeaders,
1426
+ "x-forwarded-for": [
1427
+ headers["x-forwarded-for"] ? `${headers["x-forwarded-for"]}, ${sourceIp}` : sourceIp
1428
+ ],
1429
+ "x-forwarded-proto": [url.protocol.slice(0, -1)]
1430
+ },
1431
+ body: body ?? "",
1432
+ isBase64Encoded
1433
+ };
1434
+ }
1435
+ const hasQuery = Object.keys(query).length > 0;
1436
+ return {
1437
+ resource: "/{proxy+}",
1438
+ path: url.pathname,
1439
+ httpMethod: request.method,
1440
+ headers,
1441
+ multiValueHeaders,
1442
+ queryStringParameters: hasQuery ? lastValues(query) : null,
1443
+ multiValueQueryStringParameters: hasQuery ? query : null,
1444
+ pathParameters: { proxy: url.pathname.slice(1) },
1445
+ stageVariables: null,
1446
+ requestContext: {
1447
+ accountId: ACCOUNT,
1448
+ apiId: "api",
1449
+ domainName: url.host,
1450
+ httpMethod: request.method,
1451
+ identity: { sourceIp, userAgent },
1452
+ path: url.pathname,
1453
+ protocol: "HTTP/1.1",
1454
+ requestId,
1455
+ requestTimeEpoch: Date.now(),
1456
+ resourcePath: "/{proxy+}",
1457
+ stage: "$default"
1458
+ },
1459
+ body,
1460
+ isBase64Encoded
1461
+ };
1462
+ };
1463
+ var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 204, 205, 304]);
1464
+ var fromLambdaResult = (result) => {
1465
+ const { statusCode, headers, multiValueHeaders, cookies, body, ...rest } = result;
1466
+ const responseHeaders = new Headers();
1467
+ for (const [name, value] of Object.entries(headers ?? {})) {
1468
+ for (const item of Array.isArray(value) ? value : [value]) {
1469
+ responseHeaders.append(name, item);
1470
+ }
1471
+ }
1472
+ for (const [name, values] of Object.entries(multiValueHeaders ?? {})) {
1473
+ for (const value of values) responseHeaders.append(name, value);
1474
+ }
1475
+ for (const cookie of cookies ?? []) {
1476
+ responseHeaders.append("set-cookie", cookie);
1477
+ }
1478
+ const content = !body || NULL_BODY_STATUSES.has(statusCode) ? null : rest.isBase64Encoded ? decodeBase64(body) : body;
1479
+ return new Response(content, {
1480
+ status: statusCode,
1481
+ headers: responseHeaders
1482
+ });
1483
+ };
1484
+ var lambdaContext = () => ({
1485
+ awsRequestId: crypto.randomUUID(),
1486
+ functionName: "dotpress-test",
1487
+ functionVersion: "$LATEST",
1488
+ invokedFunctionArn: `arn:aws:lambda:us-east-1:${ACCOUNT}:function:dotpress-test`,
1489
+ memoryLimitInMB: "128",
1490
+ logGroupName: "/aws/lambda/dotpress-test",
1491
+ logStreamName: "test",
1492
+ callbackWaitsForEmptyEventLoop: true,
1493
+ getRemainingTimeInMillis: () => 3e4
1494
+ });
1495
+ var createLambdaTarget = (handler, options) => {
1496
+ const invoke = handler;
1497
+ return createFetchTarget(async (request) => {
1498
+ const event = await toLambdaEvent(request, options.format, options.sourceIp);
1499
+ let result;
1500
+ try {
1501
+ result = await invoke(event, lambdaContext());
1502
+ } catch {
1503
+ return Response.json(
1504
+ { message: "Internal server error" },
1505
+ { status: 502 }
1506
+ );
1507
+ }
1508
+ return fromLambdaResult(result);
1509
+ }, options.close);
1510
+ };
1511
+
1512
+ // src/memory.ts
1513
+ import {
1514
+ DEFAULT_BODY_LIMIT as DEFAULT_BODY_LIMIT2,
1515
+ createApp,
1516
+ createRouteMatcher,
1517
+ fromWebRequest,
1518
+ readRequestBody,
1519
+ toWebResponse
1520
+ } from "@dotpress/core";
1521
+ var memoryAdapter = (options = {}) => {
1522
+ const bodyLimit = options.bodyLimit ?? DEFAULT_BODY_LIMIT2;
1523
+ let routes = [];
1524
+ let matcher;
1525
+ let runtime;
1526
+ const handle = async (request) => {
1527
+ if (!matcher || !runtime) {
1528
+ throw new Error("The memory adapter is not mounted: call createApp().");
1529
+ }
1530
+ const normalized = fromWebRequest(request, {
1531
+ ip: options.ip ?? "127.0.0.1"
1532
+ });
1533
+ const match = matcher.match(request.method, normalized.path);
1534
+ if (!match) {
1535
+ await request.body?.cancel();
1536
+ return runtime.handleNotFound(normalized);
1537
+ }
1538
+ try {
1539
+ const { body, files, rawBody } = await readRequestBody(
1540
+ request,
1541
+ match.route.definition.bodyLimit ?? bodyLimit
1542
+ );
1543
+ normalized.body = body;
1544
+ normalized.files = files;
1545
+ normalized.rawBody = rawBody;
1546
+ } catch (error) {
1547
+ return runtime.handleError(error, normalized);
1548
+ }
1549
+ normalized.params = match.params;
1550
+ return match.route.execute(normalized);
1551
+ };
1552
+ return {
1553
+ name: "memory",
1554
+ mount(compiledRoutes, dotpressRuntime) {
1555
+ if (runtime) throw new Error("The memory adapter is already mounted.");
1556
+ routes = compiledRoutes;
1557
+ matcher = createRouteMatcher(compiledRoutes);
1558
+ runtime = dotpressRuntime;
1559
+ },
1560
+ native: () => ({ routes }),
1561
+ async fetch(request) {
1562
+ return toWebResponse(await handle(request), request.method);
1563
+ }
1564
+ };
1565
+ };
1566
+ var createTestApp = async (options = {}) => {
1567
+ const { adapter: adapterOptions, ...appOptions } = options;
1568
+ const logger = appOptions.logger ?? createMemoryLogger();
1569
+ const adapter = memoryAdapter(adapterOptions);
1570
+ const app = await createApp({ ...appOptions, logger, adapter });
1571
+ return {
1572
+ app,
1573
+ adapter,
1574
+ logger,
1575
+ fetch: async (path, init) => adapter.fetch(await toRequest(path, init)),
1576
+ close: () => app.close()
1577
+ };
1578
+ };
1579
+
1580
+ // src/events.ts
1581
+ var OPTIONS = ["times", "metadata", "delay"];
1582
+ var emissionsOf = (label, source) => {
1583
+ if (typeof source === "object" && source !== null) {
1584
+ if ("emitted" in source && Array.isArray(source.emitted)) {
1585
+ return source.emitted;
1586
+ }
1587
+ if ("sent" in source && Array.isArray(source.sent)) {
1588
+ const emissions = /* @__PURE__ */ new Map();
1589
+ for (const envelope of source.sent) {
1590
+ emissions.set(envelope.emissionId, {
1591
+ emissionId: envelope.emissionId,
1592
+ event: envelope.event,
1593
+ payload: envelope.payload,
1594
+ delay: void 0,
1595
+ metadata: envelope.metadata,
1596
+ emittedAt: new Date(envelope.emittedAt)
1597
+ });
1598
+ }
1599
+ return [...emissions.values()];
1600
+ }
1601
+ }
1602
+ throw new TypeError(
1603
+ `${label}: expected a fake event bus (createFakeEventBus()) or a memory transport (createMemoryTransport()).`
1604
+ );
1605
+ };
1606
+ var nameOf = (label, event) => {
1607
+ const name = typeof event === "string" ? event : event?.name;
1608
+ if (typeof name !== "string" || name === "") {
1609
+ throw new TypeError(
1610
+ `${label}: expected an event created by defineEvent(), or its name.`
1611
+ );
1612
+ }
1613
+ return name;
1614
+ };
1615
+ var find = (label, source, event, payload, options) => {
1616
+ const name = nameOf(label, event);
1617
+ if (options !== void 0 && (typeof options !== "object" || options === null)) {
1618
+ throw new TypeError(`${label}: expected an object of options.`);
1619
+ }
1620
+ const values = options ?? {};
1621
+ for (const key of Object.keys(values)) {
1622
+ if (!OPTIONS.includes(key)) {
1623
+ throw new TypeError(`${label}: unknown option "${key}".`);
1624
+ }
1625
+ }
1626
+ checkTimes(label, values.times);
1627
+ const emissions = emissionsOf(label, source);
1628
+ if ("delay" in values && !("emitted" in source)) {
1629
+ throw new TypeError(
1630
+ `${label}: delay is only recorded by a fake event bus (createFakeEventBus()).`
1631
+ );
1632
+ }
1633
+ const found = emissions.filter(
1634
+ (emission) => emission.event === name && (payload === void 0 || matches(emission.payload, payload)) && (values.metadata === void 0 || matches(emission.metadata, values.metadata)) && (!("delay" in values) || emission.delay === values.delay)
1635
+ );
1636
+ const details = [
1637
+ payload === void 0 ? "" : ` with payload ${show(payload)}`,
1638
+ values.metadata === void 0 ? "" : ` and metadata ${show(values.metadata)}`,
1639
+ "delay" in values ? ` and delay ${String(values.delay)}` : ""
1640
+ ].join("");
1641
+ const recorded = emissions.length === 0 ? "No event was emitted." : `Emitted events:
1642
+ ${emissions.map(
1643
+ (emission) => ` - ${emission.event} ${show(emission.payload)}${emission.delay === void 0 ? "" : ` (delay ${emission.delay})`}`
1644
+ ).join("\n")}`;
1645
+ return {
1646
+ found,
1647
+ description: {
1648
+ some: `event "${name}" emitted${details}`,
1649
+ none: `no event "${name}" emitted${details}`
1650
+ },
1651
+ recorded,
1652
+ times: values.times
1653
+ };
1654
+ };
1655
+ function expectEventEmitted(source, event, ...rest) {
1656
+ const label = "expectEventEmitted()";
1657
+ const [payload, options] = rest;
1658
+ const { found, description, recorded, times } = find(
1659
+ label,
1660
+ source,
1661
+ event,
1662
+ payload,
1663
+ options
1664
+ );
1665
+ if (times === 0) {
1666
+ throw new TypeError(
1667
+ `${label}: times must be at least 1 (use expectEventNotEmitted()).`
1668
+ );
1669
+ }
1670
+ assertCount(description, found.length, times, recorded);
1671
+ return found.at(-1);
1672
+ }
1673
+ function expectEventNotEmitted(source, event, ...rest) {
1674
+ const label = "expectEventNotEmitted()";
1675
+ const { found, description, recorded } = find(
1676
+ label,
1677
+ source,
1678
+ event,
1679
+ rest[0],
1680
+ void 0
1681
+ );
1682
+ assertCount(description, found.length, 0, recorded);
1683
+ }
1684
+
1685
+ // src/mail.ts
1686
+ var ADDRESS_KEYS = ["to", "cc", "bcc", "replyTo"];
1687
+ var KEYS = /* @__PURE__ */ new Set([
1688
+ ...ADDRESS_KEYS,
1689
+ "from",
1690
+ "template",
1691
+ "locale",
1692
+ "subject",
1693
+ "html",
1694
+ "text",
1695
+ "tags",
1696
+ "metadata",
1697
+ "headers",
1698
+ "attachments"
1699
+ ]);
1700
+ var lower = (address) => address.trim().toLowerCase();
1701
+ var checkAddresses = (label, key, value) => {
1702
+ const list = typeof value === "string" ? [value] : value;
1703
+ if (!Array.isArray(list) || list.length === 0 || !list.every((item) => typeof item === "string" && item.trim() !== "")) {
1704
+ throw new TypeError(
1705
+ `${label}: ${key} must be an email address, or a non-empty array of them.`
1706
+ );
1707
+ }
1708
+ return list.map(lower);
1709
+ };
1710
+ var predicateOf = (label, criteria) => {
1711
+ if (typeof criteria !== "object" || criteria === null) {
1712
+ throw new TypeError(`${label}: expected an object of criteria.`);
1713
+ }
1714
+ const values = criteria;
1715
+ for (const key of Object.keys(values)) {
1716
+ if (!KEYS.has(key)) {
1717
+ throw new TypeError(`${label}: unknown criterion "${key}".`);
1718
+ }
1719
+ }
1720
+ const addresses = ADDRESS_KEYS.flatMap(
1721
+ (key) => values[key] === void 0 ? [] : [{ key, expected: checkAddresses(label, key, values[key]) }]
1722
+ );
1723
+ const { from, template } = values;
1724
+ if (from !== void 0 && (typeof from !== "string" || from.trim() === "")) {
1725
+ throw new TypeError(`${label}: from must be an email address.`);
1726
+ }
1727
+ const templateName = template === void 0 ? void 0 : typeof template === "string" ? template : template?.name;
1728
+ if (template !== void 0 && typeof templateName !== "string") {
1729
+ throw new TypeError(`${label}: template must be a template, or its name.`);
1730
+ }
1731
+ const others = Object.entries(values).filter(
1732
+ ([key, value]) => value !== void 0 && !ADDRESS_KEYS.includes(key) && key !== "from" && key !== "template"
1733
+ );
1734
+ return (message) => addresses.every(({ key, expected }) => {
1735
+ const actual = message[key].map(
1736
+ ({ address }) => lower(address)
1737
+ );
1738
+ return expected.every((address) => actual.includes(address));
1739
+ }) && (from === void 0 || lower(message.from.address) === lower(from)) && (templateName === void 0 || message.template === templateName) && others.every(
1740
+ ([key, value]) => matches(message[key], value)
1741
+ );
1742
+ };
1743
+ var outboxOf = (label, outbox) => {
1744
+ const sent = outbox?.sent;
1745
+ if (!Array.isArray(sent)) {
1746
+ throw new TypeError(
1747
+ `${label}: expected the memory provider of the mailer (createMemoryProvider()).`
1748
+ );
1749
+ }
1750
+ return sent;
1751
+ };
1752
+ var recipients = (addresses) => addresses.map(({ address }) => address).join(", ");
1753
+ var recordedOf = (messages) => messages.length === 0 ? "No message was sent." : `Sent messages:
1754
+ ${messages.map(
1755
+ (message) => ` - to ${recipients(message.to)}: ${show(message.subject)}${message.template === void 0 ? "" : ` (template ${message.template})`}`
1756
+ ).join("\n")}`;
1757
+ var describeCriteria = (criteria) => {
1758
+ const shown = Object.keys(criteria).length === 0 ? "" : ` ${show(criteria)}`;
1759
+ return {
1760
+ some: `a message${shown} sent`,
1761
+ none: `no message${shown} sent`
1762
+ };
1763
+ };
1764
+ var expectMailSent = (outbox, criteria = {}, options = {}) => {
1765
+ const label = "expectMailSent()";
1766
+ const messages = outboxOf(label, outbox);
1767
+ const matching = predicateOf(label, criteria);
1768
+ if (typeof options !== "object" || options === null) {
1769
+ throw new TypeError(`${label}: expected an object of options.`);
1770
+ }
1771
+ for (const key of Object.keys(options)) {
1772
+ if (key !== "times") {
1773
+ throw new TypeError(`${label}: unknown option "${key}".`);
1774
+ }
1775
+ }
1776
+ checkTimes(label, options.times);
1777
+ if (options.times === 0) {
1778
+ throw new TypeError(
1779
+ `${label}: times must be at least 1 (use expectNoMailSent()).`
1780
+ );
1781
+ }
1782
+ const found = messages.filter(matching);
1783
+ assertCount(
1784
+ describeCriteria(criteria),
1785
+ found.length,
1786
+ options.times,
1787
+ recordedOf(messages)
1788
+ );
1789
+ return found.at(-1);
1790
+ };
1791
+ var expectNoMailSent = (outbox, criteria = {}) => {
1792
+ const label = "expectNoMailSent()";
1793
+ const messages = outboxOf(label, outbox);
1794
+ const found = messages.filter(predicateOf(label, criteria));
1795
+ assertCount(describeCriteria(criteria), found.length, 0, recordedOf(messages));
1796
+ };
1797
+ export {
1798
+ ExpectationError,
1799
+ createFetchTarget,
1800
+ createHttpTarget,
1801
+ createLambdaTarget,
1802
+ createMemoryLogger,
1803
+ createTestApp,
1804
+ expectEventEmitted,
1805
+ expectEventNotEmitted,
1806
+ expectMailSent,
1807
+ expectNoMailSent,
1808
+ fromLambdaResult,
1809
+ memoryAdapter,
1810
+ runConformanceSuite,
1811
+ toLambdaEvent
1812
+ };
1813
+ //# sourceMappingURL=index.js.map