@schmock/core 2.3.0 → 2.4.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/abort.d.ts +3 -0
  4. package/dist/abort.d.ts.map +1 -0
  5. package/dist/abort.js +30 -0
  6. package/dist/builder.d.ts +31 -10
  7. package/dist/builder.d.ts.map +1 -1
  8. package/dist/builder.js +760 -190
  9. package/dist/constants.d.ts +38 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/constants.js +123 -2
  12. package/dist/errors.d.ts +14 -3
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js +41 -6
  15. package/dist/helpers.d.ts +9 -0
  16. package/dist/helpers.d.ts.map +1 -1
  17. package/dist/helpers.js +18 -2
  18. package/dist/http-helpers.d.ts +39 -4
  19. package/dist/http-helpers.d.ts.map +1 -1
  20. package/dist/http-helpers.js +147 -49
  21. package/dist/index.d.ts +292 -34
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +8 -3
  24. package/dist/interceptor.d.ts +11 -1
  25. package/dist/interceptor.d.ts.map +1 -1
  26. package/dist/interceptor.js +316 -169
  27. package/dist/parser.d.ts.map +1 -1
  28. package/dist/parser.js +12 -3
  29. package/dist/plugin-pipeline.d.ts +3 -3
  30. package/dist/plugin-pipeline.d.ts.map +1 -1
  31. package/dist/plugin-pipeline.js +29 -14
  32. package/dist/response-normalizer.d.ts +16 -0
  33. package/dist/response-normalizer.d.ts.map +1 -0
  34. package/dist/response-normalizer.js +316 -0
  35. package/dist/response-parser.d.ts.map +1 -1
  36. package/dist/response-parser.js +39 -2
  37. package/dist/route-matcher.d.ts +3 -0
  38. package/dist/route-matcher.d.ts.map +1 -1
  39. package/dist/route-matcher.js +12 -6
  40. package/dist/types.d.ts +7 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +18 -6
  43. package/src/audit-core-builder.test.ts +0 -218
  44. package/src/audit-response-guard.test.ts +0 -38
  45. package/src/binary.test.ts +0 -126
  46. package/src/binary.ts +0 -11
  47. package/src/builder.test.ts +0 -289
  48. package/src/builder.ts +0 -748
  49. package/src/constants.test.ts +0 -99
  50. package/src/constants.ts +0 -73
  51. package/src/debug.test.ts +0 -241
  52. package/src/delay.test.ts +0 -319
  53. package/src/dist-shape.test.ts +0 -35
  54. package/src/errors.test.ts +0 -223
  55. package/src/errors.ts +0 -130
  56. package/src/factory.test.ts +0 -133
  57. package/src/helpers.test.ts +0 -147
  58. package/src/helpers.ts +0 -58
  59. package/src/http-helpers.test.ts +0 -39
  60. package/src/http-helpers.ts +0 -149
  61. package/src/index.ts +0 -152
  62. package/src/interceptor.test.ts +0 -488
  63. package/src/interceptor.ts +0 -407
  64. package/src/namespace.test.ts +0 -274
  65. package/src/parser.property.test.ts +0 -630
  66. package/src/parser.test.ts +0 -148
  67. package/src/parser.ts +0 -64
  68. package/src/plugin-pipeline.ts +0 -208
  69. package/src/plugin-system.test.ts +0 -697
  70. package/src/response-parser.ts +0 -114
  71. package/src/response-parsing.test.ts +0 -333
  72. package/src/route-matcher.ts +0 -69
  73. package/src/route-matching.test.ts +0 -394
  74. package/src/server.test.ts +0 -234
  75. package/src/smart-defaults.test.ts +0 -361
  76. package/src/steps/async-support.steps.ts +0 -421
  77. package/src/steps/audit-core-builder.steps.ts +0 -188
  78. package/src/steps/audit-onerror-tuple.steps.ts +0 -83
  79. package/src/steps/basic-usage.steps.ts +0 -245
  80. package/src/steps/developer-experience.steps.ts +0 -204
  81. package/src/steps/error-handling.steps.ts +0 -555
  82. package/src/steps/fetch-interceptor.steps.ts +0 -457
  83. package/src/steps/fluent-api.steps.ts +0 -255
  84. package/src/steps/http-methods.steps.ts +0 -342
  85. package/src/steps/lifecycle-events.steps.ts +0 -149
  86. package/src/steps/performance-reliability.steps.ts +0 -90
  87. package/src/steps/plugin-integration.steps.ts +0 -334
  88. package/src/steps/request-history.steps.ts +0 -458
  89. package/src/steps/response-delay.steps.ts +0 -88
  90. package/src/steps/route-key-format.steps.ts +0 -99
  91. package/src/steps/standalone-server.steps.ts +0 -227
  92. package/src/steps/state-concurrency.steps.ts +0 -167
  93. package/src/types.ts +0 -36
@@ -1,630 +0,0 @@
1
- import fc from "fast-check";
2
- import { describe, expect, it } from "vitest";
3
- import { RouteParseError } from "./errors";
4
- import { schmock } from "./index";
5
- import { parseRouteKey } from "./parser";
6
-
7
- const HTTP_METHODS = [
8
- "GET",
9
- "POST",
10
- "PUT",
11
- "DELETE",
12
- "PATCH",
13
- "HEAD",
14
- "OPTIONS",
15
- ] as const;
16
-
17
- const method = fc.constantFrom(...HTTP_METHODS);
18
-
19
- // --- Segment generators, from tame to hostile ---
20
-
21
- const safeSeg = fc.stringMatching(/^[a-z0-9_-]{1,20}$/);
22
- const upperSeg = fc.stringMatching(/^[A-Za-z0-9]{1,20}$/);
23
- // fc.string() in v4 generates full unicode by default
24
- const unicodeSeg = fc
25
- .string({ minLength: 1, maxLength: 20 })
26
- .filter((s) => !s.includes("/") && !s.includes("\0"));
27
- const regexMetaSeg = fc.stringMatching(/^[.*+?^${}()|[\]\\]{1,10}$/);
28
- const encodedSeg = safeSeg.map((s) => encodeURIComponent(s));
29
- const doubleEncodedSeg = safeSeg.map((s) =>
30
- encodeURIComponent(encodeURIComponent(s)),
31
- );
32
- const nullByteSeg = safeSeg.map((s) => `${s}\x00`);
33
- const whitespaceSeg = fc.stringMatching(/^[ \t]{1,5}$/);
34
- const traversalSeg = fc.constantFrom(
35
- "..",
36
- ".",
37
- "%2e%2e",
38
- "%2e",
39
- "..%2f",
40
- "%2f..",
41
- );
42
- const emptySeg = fc.constant("");
43
- const emojiSeg = fc.constantFrom(
44
- "\u{1F600}",
45
- "\u{1F4A9}",
46
- "\u{1F1E7}\u{1F1F7}",
47
- "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
48
- "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}",
49
- "\u{0041}\u{030A}",
50
- "\u{2764}\u{FE0F}",
51
- "caf\u{00E9}",
52
- "\u{D55C}\u{AD6D}\u{C5B4}",
53
- "\u{0639}\u{0631}\u{0628}\u{064A}",
54
- );
55
- const mixedEmojiSeg = fc.tuple(safeSeg, emojiSeg).map(([a, b]) => `${a}${b}`);
56
-
57
- const hostileSeg = fc.oneof(
58
- { weight: 3, arbitrary: safeSeg },
59
- { weight: 2, arbitrary: unicodeSeg },
60
- { weight: 2, arbitrary: regexMetaSeg },
61
- { weight: 2, arbitrary: emojiSeg },
62
- { weight: 1, arbitrary: mixedEmojiSeg },
63
- { weight: 1, arbitrary: encodedSeg },
64
- { weight: 1, arbitrary: doubleEncodedSeg },
65
- { weight: 1, arbitrary: nullByteSeg },
66
- { weight: 1, arbitrary: whitespaceSeg },
67
- { weight: 1, arbitrary: traversalSeg },
68
- { weight: 1, arbitrary: emptySeg },
69
- { weight: 1, arbitrary: upperSeg },
70
- );
71
-
72
- // --- Path generators ---
73
-
74
- const safePath = fc
75
- .array(safeSeg, { minLength: 1, maxLength: 6 })
76
- .map((segs) => `/${segs.join("/")}`);
77
-
78
- const hostilePath = fc
79
- .array(hostileSeg, { minLength: 1, maxLength: 6 })
80
- .map((segs) => `/${segs.join("/")}`);
81
-
82
- const doubleSlashPath = fc
83
- .array(safeSeg, { minLength: 2, maxLength: 4 })
84
- .map((segs) => `/${segs.join("//")}`);
85
-
86
- const deepPath = fc
87
- .array(safeSeg, { minLength: 10, maxLength: 30 })
88
- .map((segs) => `/${segs.join("/")}`);
89
-
90
- // --- Route key generators ---
91
-
92
- const routeKey = fc.tuple(method, safePath).map(([m, p]) => `${m} ${p}`);
93
-
94
- const paramRouteKey = fc
95
- .tuple(
96
- method,
97
- fc.array(safeSeg, { minLength: 1, maxLength: 4 }),
98
- fc.array(safeSeg, { minLength: 1, maxLength: 3 }),
99
- )
100
- .map(([m, statics, params]) => {
101
- const parts = statics.map((s, i) =>
102
- i < params.length ? `${s}/:${params[i]}` : s,
103
- );
104
- return `${m} /${parts.join("/")}`;
105
- });
106
-
107
- const parameterizedRouteCase = fc
108
- .tuple(
109
- method,
110
- fc.uniqueArray(
111
- fc.record({
112
- literal: safeSeg,
113
- parameter: safeSeg,
114
- value: safeSeg,
115
- }),
116
- {
117
- minLength: 1,
118
- maxLength: 4,
119
- selector: (part) => part.parameter,
120
- },
121
- ),
122
- )
123
- .map(([httpMethod, parts]) => ({
124
- key: `${httpMethod} /${parts
125
- .map((part) => `${part.literal}/:${part.parameter}`)
126
- .join("/")}`,
127
- concretePath: `/${parts
128
- .map((part) => `${part.literal}/${part.value}`)
129
- .join("/")}`,
130
- parameterNames: parts.map((part) => part.parameter),
131
- parameterValues: parts.map((part) => part.value),
132
- }));
133
-
134
- // --- Arbitrary inputs ---
135
-
136
- const asciiString = fc
137
- .array(fc.integer({ min: 0, max: 0x7f }), {
138
- minLength: 0,
139
- maxLength: 200,
140
- })
141
- .map((codePoints) => String.fromCharCode(...codePoints));
142
- const unicodeString = fc.string({ minLength: 0, maxLength: 200 });
143
-
144
- function expectParseResultOrRouteError(input: string): void {
145
- let result: ReturnType<typeof parseRouteKey>;
146
- try {
147
- result = parseRouteKey(input);
148
- } catch (error) {
149
- expect(error).toBeInstanceOf(RouteParseError);
150
- return;
151
- }
152
-
153
- expect(`${result.method} ${result.path}`).toBe(input);
154
- expect(result.pattern.test(result.path)).toBe(true);
155
- }
156
-
157
- // --- Namespace generators ---
158
-
159
- const namespace = fc.oneof(
160
- safeSeg.map((s) => `/${s}`),
161
- fc.constant("/"),
162
- safeSeg, // no leading slash
163
- safeSeg.map((s) => `/${s}/`), // trailing slash
164
- fc.constant(""),
165
- safeSeg.map((s) => `//${s}`), // double leading slash
166
- fc.tuple(safeSeg, safeSeg).map(([a, b]) => `/${a}/${b}`), // nested
167
- );
168
-
169
- // ============================================================
170
- // parseRouteKey properties
171
- // ============================================================
172
-
173
- describe("parseRouteKey — fuzz", () => {
174
- it("never throws an unhandled error on arbitrary ASCII input", () => {
175
- fc.assert(fc.property(asciiString, expectParseResultOrRouteError), {
176
- numRuns: 1000,
177
- });
178
- });
179
-
180
- it("never throws an unhandled error on arbitrary unicode input", () => {
181
- fc.assert(fc.property(unicodeString, expectParseResultOrRouteError), {
182
- numRuns: 1000,
183
- });
184
- });
185
-
186
- it("escapes regex metacharacters in literal path segments", () => {
187
- fc.assert(
188
- fc.property(
189
- method,
190
- safeSeg,
191
- regexMetaSeg,
192
- safeSeg,
193
- (httpMethod, prefix, literal, suffix) => {
194
- const result = parseRouteKey(
195
- `${httpMethod} /${prefix}/${literal}/${suffix}`,
196
- );
197
-
198
- expect(result.pattern.test(result.path)).toBe(true);
199
- expect(result.pattern.test(`/${prefix}/literal/${suffix}`)).toBe(
200
- false,
201
- );
202
- },
203
- ),
204
- { numRuns: 500 },
205
- );
206
- });
207
-
208
- it("always produces a valid regex for well-formed route keys", () => {
209
- fc.assert(
210
- fc.property(routeKey, (key) => {
211
- const result = parseRouteKey(key);
212
- expect(result.pattern).toBeInstanceOf(RegExp);
213
- expect(result.pattern.test(result.path)).toBe(true);
214
- }),
215
- { numRuns: 500 },
216
- );
217
- });
218
-
219
- it("produced regex never matches a completely unrelated path", () => {
220
- fc.assert(
221
- fc.property(routeKey, (key) => {
222
- const result = parseRouteKey(key);
223
- // An empty string should never match a route pattern
224
- expect(result.pattern.test("")).toBe(false);
225
- }),
226
- { numRuns: 500 },
227
- );
228
- });
229
-
230
- it("matches concrete parameterized paths and captures every value", () => {
231
- fc.assert(
232
- fc.property(parameterizedRouteCase, (routeCase) => {
233
- const result = parseRouteKey(routeCase.key);
234
- const match = result.pattern.exec(routeCase.concretePath);
235
-
236
- expect(match).not.toBeNull();
237
- expect(match?.slice(1)).toEqual(routeCase.parameterValues);
238
- expect(result.params).toEqual(routeCase.parameterNames);
239
- }),
240
- { numRuns: 1000 },
241
- );
242
- });
243
-
244
- it("rejects slash-delimited near misses for parameterized paths", () => {
245
- fc.assert(
246
- fc.property(parameterizedRouteCase, safeSeg, (routeCase, extra) => {
247
- const result = parseRouteKey(routeCase.key);
248
- const nearMiss = `${routeCase.concretePath}/${extra}`;
249
-
250
- expect(result.pattern.test(nearMiss)).toBe(false);
251
- }),
252
- { numRuns: 500 },
253
- );
254
- });
255
-
256
- it("does not normalize malformed separators while matching", () => {
257
- fc.assert(
258
- fc.property(routeKey, (key) => {
259
- const result = parseRouteKey(key);
260
- const doubledLeadingSlash = `/${result.path}`;
261
- expect(result.pattern.test(doubledLeadingSlash)).toBe(false);
262
- }),
263
- { numRuns: 500 },
264
- );
265
- });
266
-
267
- it("parameter count always matches colon segments", () => {
268
- fc.assert(
269
- fc.property(paramRouteKey, (key) => {
270
- const result = parseRouteKey(key);
271
- const colonCount = (key.match(/:/g) || []).length;
272
- expect(result.params).toHaveLength(colonCount);
273
- }),
274
- { numRuns: 500 },
275
- );
276
- });
277
-
278
- it("parameter names never contain slashes", () => {
279
- fc.assert(
280
- fc.property(paramRouteKey, (key) => {
281
- const result = parseRouteKey(key);
282
- for (const param of result.params) {
283
- expect(param).not.toContain("/");
284
- }
285
- }),
286
- { numRuns: 500 },
287
- );
288
- });
289
-
290
- it("pattern is always anchored (^ and $)", () => {
291
- fc.assert(
292
- fc.property(routeKey, (key) => {
293
- const result = parseRouteKey(key);
294
- const src = result.pattern.source;
295
- expect(src.startsWith("^")).toBe(true);
296
- expect(src.endsWith("$")).toBe(true);
297
- }),
298
- { numRuns: 300 },
299
- );
300
- });
301
-
302
- it("method in result always matches method in input", () => {
303
- fc.assert(
304
- fc.property(routeKey, (key) => {
305
- const result = parseRouteKey(key);
306
- const inputMethod = key.split(" ")[0];
307
- expect(result.method).toBe(inputMethod);
308
- }),
309
- { numRuns: 300 },
310
- );
311
- });
312
- });
313
-
314
- // ============================================================
315
- // handle() properties
316
- // ============================================================
317
-
318
- describe("handle() — fuzz", () => {
319
- it("never throws on arbitrary request paths", () => {
320
- const mock = schmock();
321
- mock("GET /users/:id", () => ({ id: 1 }));
322
- mock("POST /items", () => ({ ok: true }));
323
- mock("DELETE /items/:id/comments/:cid", () => ({ deleted: true }));
324
-
325
- fc.assert(
326
- fc.asyncProperty(method, asciiString, async (m, reqPath) => {
327
- const response = await mock.handle(m, reqPath);
328
- expect(response).toBeDefined();
329
- expect(typeof response.status).toBe("number");
330
- expect([200, 404, 500]).toContain(response.status);
331
- }),
332
- { numRuns: 1000 },
333
- );
334
- });
335
-
336
- it("never throws on arbitrary unicode request paths", () => {
337
- const mock = schmock();
338
- mock("GET /data/:key", () => ({ ok: true }));
339
-
340
- fc.assert(
341
- fc.asyncProperty(unicodeString, async (reqPath) => {
342
- const response = await mock.handle("GET", reqPath);
343
- expect(response).toBeDefined();
344
- expect(typeof response.status).toBe("number");
345
- }),
346
- { numRuns: 500 },
347
- );
348
- });
349
-
350
- it("never throws on hostile path segments", () => {
351
- const mock = schmock();
352
- mock("GET /api/:resource/:id", () => ({ found: true }));
353
-
354
- fc.assert(
355
- fc.asyncProperty(hostilePath, async (reqPath) => {
356
- const response = await mock.handle("GET", reqPath);
357
- expect(response).toBeDefined();
358
- expect(typeof response.status).toBe("number");
359
- }),
360
- { numRuns: 500 },
361
- );
362
- });
363
-
364
- it("never throws on double-slash paths", () => {
365
- const mock = schmock();
366
- mock("GET /a/:b", () => ({ ok: true }));
367
-
368
- fc.assert(
369
- fc.asyncProperty(doubleSlashPath, async (reqPath) => {
370
- const response = await mock.handle("GET", reqPath);
371
- expect(response).toBeDefined();
372
- expect(typeof response.status).toBe("number");
373
- }),
374
- { numRuns: 300 },
375
- );
376
- });
377
-
378
- it("never throws on deeply nested paths", () => {
379
- const mock = schmock();
380
- mock("GET /a/:b", () => ({ ok: true }));
381
-
382
- fc.assert(
383
- fc.asyncProperty(deepPath, async (reqPath) => {
384
- const response = await mock.handle("GET", reqPath);
385
- expect(response).toBeDefined();
386
- }),
387
- { numRuns: 200 },
388
- );
389
- });
390
-
391
- it("matched params are always strings", () => {
392
- let capturedParams: Record<string, string> = {};
393
- const mock = schmock();
394
- mock("GET /x/:a/:b", (ctx) => {
395
- capturedParams = ctx.params;
396
- return { ok: true };
397
- });
398
-
399
- fc.assert(
400
- fc.asyncProperty(hostileSeg, hostileSeg, async (a, b) => {
401
- // Avoid slashes in segments since they'd split the path differently
402
- if (a.includes("/") || b.includes("/")) return;
403
- const response = await mock.handle("GET", `/x/${a}/${b}`);
404
- if (response.status === 200) {
405
- expect(typeof capturedParams.a).toBe("string");
406
- expect(typeof capturedParams.b).toBe("string");
407
- expect(capturedParams.a).toBe(a);
408
- expect(capturedParams.b).toBe(b);
409
- }
410
- }),
411
- { numRuns: 500 },
412
- );
413
- });
414
-
415
- it("emoji params are captured verbatim", () => {
416
- let capturedParams: Record<string, string> = {};
417
- const mock = schmock();
418
- mock("GET /emoji/:val", (ctx) => {
419
- capturedParams = ctx.params;
420
- return { ok: true };
421
- });
422
-
423
- fc.assert(
424
- fc.asyncProperty(emojiSeg, async (emoji) => {
425
- const response = await mock.handle("GET", `/emoji/${emoji}`);
426
- expect(response.status).toBe(200);
427
- expect(capturedParams.val).toBe(emoji);
428
- }),
429
- { numRuns: 100 },
430
- );
431
- });
432
-
433
- it("mixed emoji + ASCII paths resolve without error", () => {
434
- const mock = schmock();
435
- mock("GET /search/:query", () => ({ results: [] }));
436
-
437
- fc.assert(
438
- fc.asyncProperty(mixedEmojiSeg, async (seg) => {
439
- const response = await mock.handle("GET", `/search/${seg}`);
440
- expect(response).toBeDefined();
441
- expect(typeof response.status).toBe("number");
442
- }),
443
- { numRuns: 200 },
444
- );
445
- });
446
-
447
- it("namespace stripping handles arbitrary prefixes", () => {
448
- fc.assert(
449
- fc.asyncProperty(namespace, hostilePath, async (ns, reqPath) => {
450
- const mock = schmock({ namespace: ns });
451
- mock("GET /test", () => ({ ok: true }));
452
- const response = await mock.handle("GET", reqPath);
453
- expect(response).toBeDefined();
454
- expect(typeof response.status).toBe("number");
455
- }),
456
- { numRuns: 500 },
457
- );
458
- });
459
-
460
- it("namespace + valid route resolves correctly", () => {
461
- fc.assert(
462
- fc.asyncProperty(safeSeg, async (ns) => {
463
- const mock = schmock({ namespace: `/${ns}` });
464
- mock("GET /ping", () => ({ pong: true }));
465
- const response = await mock.handle("GET", `/${ns}/ping`);
466
- expect(response.status).toBe(200);
467
- }),
468
- { numRuns: 300 },
469
- );
470
- });
471
-
472
- it("unmatched routes always return 404 with ROUTE_NOT_FOUND code", () => {
473
- const mock = schmock();
474
- mock("GET /only-this", () => ({ ok: true }));
475
-
476
- // Generate paths that definitely won't match "/only-this"
477
- const nonMatchingPath = fc
478
- .array(safeSeg, { minLength: 2, maxLength: 5 })
479
- .map((segs) => `/${segs.join("/")}`);
480
-
481
- fc.assert(
482
- fc.asyncProperty(nonMatchingPath, async (reqPath) => {
483
- const response = await mock.handle("GET", reqPath);
484
- expect(response.status).toBe(404);
485
- expect((response.body as any).code).toBe("ROUTE_NOT_FOUND");
486
- }),
487
- { numRuns: 500 },
488
- );
489
- });
490
-
491
- it("regex matching terminates quickly (no ReDoS)", () => {
492
- const mock = schmock();
493
- mock("GET /a/:b/:c/:d/:e", () => ({ ok: true }));
494
-
495
- const longRepeated = fc
496
- .integer({ min: 100, max: 1000 })
497
- .map((n) => `/${"a/".repeat(n)}z`);
498
-
499
- fc.assert(
500
- fc.asyncProperty(longRepeated, async (reqPath) => {
501
- const start = performance.now();
502
- const response = await mock.handle("GET", reqPath);
503
- const elapsed = performance.now() - start;
504
- expect(response).toBeDefined();
505
- expect(elapsed).toBeLessThan(50);
506
- }),
507
- { numRuns: 200 },
508
- );
509
- });
510
-
511
- it("many registered routes still resolve without delay", () => {
512
- const mock = schmock();
513
- // Register 100 routes
514
- for (let i = 0; i < 100; i++) {
515
- mock(`GET /r${i}/:id` as any, () => ({ route: i }));
516
- }
517
-
518
- fc.assert(
519
- fc.asyncProperty(hostilePath, async (reqPath) => {
520
- const start = performance.now();
521
- const response = await mock.handle("GET", reqPath);
522
- const elapsed = performance.now() - start;
523
- expect(response).toBeDefined();
524
- expect(elapsed).toBeLessThan(50);
525
- }),
526
- { numRuns: 300 },
527
- );
528
- });
529
- });
530
-
531
- // ============================================================
532
- // Route matching round-trip properties
533
- // ============================================================
534
-
535
- describe("route matching round-trip — property", () => {
536
- it("parameterized route: arbitrary param values are extracted correctly", () => {
537
- fc.assert(
538
- fc.asyncProperty(
539
- safeSeg,
540
- safeSeg,
541
- safeSeg,
542
- async (resource, paramA, paramB) => {
543
- const mock = schmock();
544
- let capturedParams: Record<string, string> = {};
545
- mock(`GET /${resource}/:a/:b` as any, (ctx) => {
546
- capturedParams = ctx.params;
547
- return { ok: true };
548
- });
549
-
550
- const response = await mock.handle(
551
- "GET",
552
- `/${resource}/${paramA}/${paramB}`,
553
- );
554
- expect(response.status).toBe(200);
555
- expect(capturedParams.a).toBe(paramA);
556
- expect(capturedParams.b).toBe(paramB);
557
- },
558
- ),
559
- { numRuns: 300 },
560
- );
561
- });
562
-
563
- it("any valid response format produces a valid Response with status/body/headers", () => {
564
- const responseGen = fc.oneof(
565
- // Object body
566
- fc.record({ key: safeSeg }),
567
- // Tuple [status, body]
568
- fc.tuple(fc.integer({ min: 100, max: 599 }), fc.record({ v: safeSeg })),
569
- // Tuple [status, body, headers]
570
- fc.tuple(
571
- fc.integer({ min: 100, max: 599 }),
572
- fc.record({ v: safeSeg }),
573
- fc.constant({}),
574
- ),
575
- // null/undefined
576
- fc.constant(null),
577
- fc.constant(undefined),
578
- );
579
-
580
- fc.assert(
581
- fc.asyncProperty(responseGen, async (genValue) => {
582
- const mock = schmock();
583
- mock("GET /test", () => genValue);
584
-
585
- const response = await mock.handle("GET", "/test");
586
- expect(typeof response.status).toBe("number");
587
- expect(response.status).toBeGreaterThanOrEqual(100);
588
- expect(response.status).toBeLessThanOrEqual(599);
589
- expect(response).toHaveProperty("headers");
590
- expect(typeof response.headers).toBe("object");
591
- }),
592
- { numRuns: 300 },
593
- );
594
- });
595
-
596
- it("header keys are always lowercased after extractHeaders in interceptor", () => {
597
- const headerKey = fc
598
- .stringMatching(/^[A-Za-z][A-Za-z0-9-]{0,20}$/)
599
- .filter((k) => k.length > 0);
600
- const headerVal = safeSeg;
601
-
602
- fc.assert(
603
- fc.asyncProperty(headerKey, headerVal, async (key, val) => {
604
- const mock = schmock();
605
- let capturedHeaders: Record<string, string> = {};
606
- mock("GET /hdr", (ctx) => {
607
- capturedHeaders = ctx.headers;
608
- return { ok: true };
609
- });
610
- const handle = mock.intercept();
611
-
612
- try {
613
- await fetch("http://localhost/hdr", {
614
- headers: { [key]: val },
615
- });
616
-
617
- // All header keys should be lowercase
618
- for (const hdrKey of Object.keys(capturedHeaders)) {
619
- expect(hdrKey).toBe(hdrKey.toLowerCase());
620
- }
621
- // The value should be preserved
622
- expect(capturedHeaders[key.toLowerCase()]).toBe(val);
623
- } finally {
624
- handle.restore();
625
- }
626
- }),
627
- { numRuns: 200 },
628
- );
629
- });
630
- });