@schmock/core 1.0.4 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builder.d.ts +15 -5
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +148 -60
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +11 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -12
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +2 -17
- package/dist/types.d.ts +17 -214
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/package.json +4 -4
- package/src/builder.test.ts +2 -2
- package/src/builder.ts +226 -108
- package/src/constants.ts +17 -1
- package/src/index.ts +34 -28
- package/src/namespace.test.ts +3 -2
- package/src/parser.property.test.ts +493 -0
- package/src/parser.ts +2 -20
- package/src/plugin-system.test.ts +91 -0
- package/src/response-parsing.test.ts +11 -7
- package/src/steps/async-support.steps.ts +101 -91
- package/src/steps/basic-usage.steps.ts +49 -36
- package/src/steps/developer-experience.steps.ts +95 -97
- package/src/steps/error-handling.steps.ts +71 -72
- package/src/steps/fluent-api.steps.ts +75 -72
- package/src/steps/http-methods.steps.ts +33 -33
- package/src/steps/performance-reliability.steps.ts +52 -88
- package/src/steps/plugin-integration.steps.ts +176 -176
- package/src/steps/request-history.steps.ts +333 -0
- package/src/steps/state-concurrency.steps.ts +418 -316
- package/src/steps/stateful-workflows.steps.ts +138 -136
- package/src/types.ts +20 -271
|
@@ -0,0 +1,493 @@
|
|
|
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 trailingSlashPath = safePath.map((p) => `${p}/`);
|
|
87
|
+
|
|
88
|
+
const deepPath = fc
|
|
89
|
+
.array(safeSeg, { minLength: 10, maxLength: 30 })
|
|
90
|
+
.map((segs) => `/${segs.join("/")}`);
|
|
91
|
+
|
|
92
|
+
// --- Route key generators ---
|
|
93
|
+
|
|
94
|
+
const routeKey = fc.tuple(method, safePath).map(([m, p]) => `${m} ${p}`);
|
|
95
|
+
|
|
96
|
+
const paramRouteKey = fc
|
|
97
|
+
.tuple(
|
|
98
|
+
method,
|
|
99
|
+
fc.array(safeSeg, { minLength: 1, maxLength: 4 }),
|
|
100
|
+
fc.array(safeSeg, { minLength: 1, maxLength: 3 }),
|
|
101
|
+
)
|
|
102
|
+
.map(([m, statics, params]) => {
|
|
103
|
+
const parts = statics.map((s, i) =>
|
|
104
|
+
i < params.length ? `${s}/:${params[i]}` : s,
|
|
105
|
+
);
|
|
106
|
+
return `${m} /${parts.join("/")}`;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Route keys with hostile characters in the path portion
|
|
110
|
+
const hostileRouteKey = fc
|
|
111
|
+
.tuple(method, hostilePath)
|
|
112
|
+
.map(([m, p]) => `${m} ${p}`);
|
|
113
|
+
|
|
114
|
+
// --- Arbitrary inputs ---
|
|
115
|
+
|
|
116
|
+
const arbitraryString = fc.string({ minLength: 0, maxLength: 200 });
|
|
117
|
+
|
|
118
|
+
// --- Namespace generators ---
|
|
119
|
+
|
|
120
|
+
const namespace = fc.oneof(
|
|
121
|
+
safeSeg.map((s) => `/${s}`),
|
|
122
|
+
fc.constant("/"),
|
|
123
|
+
safeSeg, // no leading slash
|
|
124
|
+
safeSeg.map((s) => `/${s}/`), // trailing slash
|
|
125
|
+
fc.constant(""),
|
|
126
|
+
safeSeg.map((s) => `//${s}`), // double leading slash
|
|
127
|
+
fc.tuple(safeSeg, safeSeg).map(([a, b]) => `/${a}/${b}`), // nested
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// ============================================================
|
|
131
|
+
// parseRouteKey properties
|
|
132
|
+
// ============================================================
|
|
133
|
+
|
|
134
|
+
describe("parseRouteKey — fuzz", () => {
|
|
135
|
+
it("never throws an unhandled error on arbitrary ASCII input", () => {
|
|
136
|
+
fc.assert(
|
|
137
|
+
fc.property(arbitraryString, (input) => {
|
|
138
|
+
try {
|
|
139
|
+
parseRouteKey(input);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
expect(e).toBeInstanceOf(RouteParseError);
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
{ numRuns: 1000 },
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("never throws an unhandled error on arbitrary unicode input", () => {
|
|
149
|
+
fc.assert(
|
|
150
|
+
fc.property(arbitraryString, (input) => {
|
|
151
|
+
try {
|
|
152
|
+
parseRouteKey(input);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
expect(e).toBeInstanceOf(RouteParseError);
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
{ numRuns: 1000 },
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects inputs that look like methods but have hostile paths", () => {
|
|
162
|
+
fc.assert(
|
|
163
|
+
fc.property(hostileRouteKey, (key) => {
|
|
164
|
+
try {
|
|
165
|
+
const result = parseRouteKey(key);
|
|
166
|
+
// If it parses, the regex must be valid
|
|
167
|
+
expect(result.pattern).toBeInstanceOf(RegExp);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
expect(e).toBeInstanceOf(RouteParseError);
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
{ numRuns: 500 },
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("always produces a valid regex for well-formed route keys", () => {
|
|
177
|
+
fc.assert(
|
|
178
|
+
fc.property(routeKey, (key) => {
|
|
179
|
+
const result = parseRouteKey(key);
|
|
180
|
+
expect(result.pattern).toBeInstanceOf(RegExp);
|
|
181
|
+
expect(result.pattern.test(result.path)).toBe(true);
|
|
182
|
+
}),
|
|
183
|
+
{ numRuns: 500 },
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("produced regex never matches a completely unrelated path", () => {
|
|
188
|
+
fc.assert(
|
|
189
|
+
fc.property(routeKey, (key) => {
|
|
190
|
+
const result = parseRouteKey(key);
|
|
191
|
+
// An empty string should never match a route pattern
|
|
192
|
+
expect(result.pattern.test("")).toBe(false);
|
|
193
|
+
}),
|
|
194
|
+
{ numRuns: 500 },
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("produced regex is safe against hostile test paths", () => {
|
|
199
|
+
fc.assert(
|
|
200
|
+
fc.property(routeKey, hostilePath, (key, reqPath) => {
|
|
201
|
+
const result = parseRouteKey(key);
|
|
202
|
+
const matched = result.pattern.test(reqPath);
|
|
203
|
+
expect(typeof matched).toBe("boolean");
|
|
204
|
+
}),
|
|
205
|
+
{ numRuns: 1000 },
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("produced regex handles double-slash paths without crashing", () => {
|
|
210
|
+
fc.assert(
|
|
211
|
+
fc.property(routeKey, doubleSlashPath, (key, reqPath) => {
|
|
212
|
+
const result = parseRouteKey(key);
|
|
213
|
+
const matched = result.pattern.test(reqPath);
|
|
214
|
+
expect(typeof matched).toBe("boolean");
|
|
215
|
+
}),
|
|
216
|
+
{ numRuns: 300 },
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("produced regex handles trailing-slash paths without crashing", () => {
|
|
221
|
+
fc.assert(
|
|
222
|
+
fc.property(routeKey, trailingSlashPath, (key, reqPath) => {
|
|
223
|
+
const result = parseRouteKey(key);
|
|
224
|
+
const matched = result.pattern.test(reqPath);
|
|
225
|
+
expect(typeof matched).toBe("boolean");
|
|
226
|
+
}),
|
|
227
|
+
{ numRuns: 300 },
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("parameter count always matches colon segments", () => {
|
|
232
|
+
fc.assert(
|
|
233
|
+
fc.property(paramRouteKey, (key) => {
|
|
234
|
+
const result = parseRouteKey(key);
|
|
235
|
+
const colonCount = (key.match(/:/g) || []).length;
|
|
236
|
+
expect(result.params).toHaveLength(colonCount);
|
|
237
|
+
}),
|
|
238
|
+
{ numRuns: 500 },
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("parameter names never contain slashes", () => {
|
|
243
|
+
fc.assert(
|
|
244
|
+
fc.property(paramRouteKey, (key) => {
|
|
245
|
+
const result = parseRouteKey(key);
|
|
246
|
+
for (const param of result.params) {
|
|
247
|
+
expect(param).not.toContain("/");
|
|
248
|
+
}
|
|
249
|
+
}),
|
|
250
|
+
{ numRuns: 500 },
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("pattern is always anchored (^ and $)", () => {
|
|
255
|
+
fc.assert(
|
|
256
|
+
fc.property(routeKey, (key) => {
|
|
257
|
+
const result = parseRouteKey(key);
|
|
258
|
+
const src = result.pattern.source;
|
|
259
|
+
expect(src.startsWith("^")).toBe(true);
|
|
260
|
+
expect(src.endsWith("$")).toBe(true);
|
|
261
|
+
}),
|
|
262
|
+
{ numRuns: 300 },
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("method in result always matches method in input", () => {
|
|
267
|
+
fc.assert(
|
|
268
|
+
fc.property(routeKey, (key) => {
|
|
269
|
+
const result = parseRouteKey(key);
|
|
270
|
+
const inputMethod = key.split(" ")[0];
|
|
271
|
+
expect(result.method).toBe(inputMethod);
|
|
272
|
+
}),
|
|
273
|
+
{ numRuns: 300 },
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ============================================================
|
|
279
|
+
// handle() properties
|
|
280
|
+
// ============================================================
|
|
281
|
+
|
|
282
|
+
describe("handle() — fuzz", () => {
|
|
283
|
+
it("never throws on arbitrary request paths", () => {
|
|
284
|
+
const mock = schmock();
|
|
285
|
+
mock("GET /users/:id", () => ({ id: 1 }));
|
|
286
|
+
mock("POST /items", () => ({ ok: true }));
|
|
287
|
+
mock("DELETE /items/:id/comments/:cid", () => ({ deleted: true }));
|
|
288
|
+
|
|
289
|
+
fc.assert(
|
|
290
|
+
fc.asyncProperty(method, arbitraryString, async (m, reqPath) => {
|
|
291
|
+
const response = await mock.handle(m, reqPath);
|
|
292
|
+
expect(response).toBeDefined();
|
|
293
|
+
expect(typeof response.status).toBe("number");
|
|
294
|
+
expect([200, 404, 500]).toContain(response.status);
|
|
295
|
+
}),
|
|
296
|
+
{ numRuns: 1000 },
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("never throws on arbitrary unicode request paths", () => {
|
|
301
|
+
const mock = schmock();
|
|
302
|
+
mock("GET /data/:key", () => ({ ok: true }));
|
|
303
|
+
|
|
304
|
+
fc.assert(
|
|
305
|
+
fc.asyncProperty(arbitraryString, async (reqPath) => {
|
|
306
|
+
const response = await mock.handle("GET", reqPath);
|
|
307
|
+
expect(response).toBeDefined();
|
|
308
|
+
expect(typeof response.status).toBe("number");
|
|
309
|
+
}),
|
|
310
|
+
{ numRuns: 500 },
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("never throws on hostile path segments", () => {
|
|
315
|
+
const mock = schmock();
|
|
316
|
+
mock("GET /api/:resource/:id", () => ({ found: true }));
|
|
317
|
+
|
|
318
|
+
fc.assert(
|
|
319
|
+
fc.asyncProperty(hostilePath, async (reqPath) => {
|
|
320
|
+
const response = await mock.handle("GET", reqPath);
|
|
321
|
+
expect(response).toBeDefined();
|
|
322
|
+
expect(typeof response.status).toBe("number");
|
|
323
|
+
}),
|
|
324
|
+
{ numRuns: 500 },
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("never throws on double-slash paths", () => {
|
|
329
|
+
const mock = schmock();
|
|
330
|
+
mock("GET /a/:b", () => ({ ok: true }));
|
|
331
|
+
|
|
332
|
+
fc.assert(
|
|
333
|
+
fc.asyncProperty(doubleSlashPath, async (reqPath) => {
|
|
334
|
+
const response = await mock.handle("GET", reqPath);
|
|
335
|
+
expect(response).toBeDefined();
|
|
336
|
+
expect(typeof response.status).toBe("number");
|
|
337
|
+
}),
|
|
338
|
+
{ numRuns: 300 },
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("never throws on deeply nested paths", () => {
|
|
343
|
+
const mock = schmock();
|
|
344
|
+
mock("GET /a/:b", () => ({ ok: true }));
|
|
345
|
+
|
|
346
|
+
fc.assert(
|
|
347
|
+
fc.asyncProperty(deepPath, async (reqPath) => {
|
|
348
|
+
const response = await mock.handle("GET", reqPath);
|
|
349
|
+
expect(response).toBeDefined();
|
|
350
|
+
}),
|
|
351
|
+
{ numRuns: 200 },
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("matched params are always strings", () => {
|
|
356
|
+
let capturedParams: Record<string, string> = {};
|
|
357
|
+
const mock = schmock();
|
|
358
|
+
mock("GET /x/:a/:b", (ctx) => {
|
|
359
|
+
capturedParams = ctx.params;
|
|
360
|
+
return { ok: true };
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
fc.assert(
|
|
364
|
+
fc.asyncProperty(hostileSeg, hostileSeg, async (a, b) => {
|
|
365
|
+
// Avoid slashes in segments since they'd split the path differently
|
|
366
|
+
if (a.includes("/") || b.includes("/")) return;
|
|
367
|
+
const response = await mock.handle("GET", `/x/${a}/${b}`);
|
|
368
|
+
if (response.status === 200) {
|
|
369
|
+
expect(typeof capturedParams.a).toBe("string");
|
|
370
|
+
expect(typeof capturedParams.b).toBe("string");
|
|
371
|
+
expect(capturedParams.a).toBe(a);
|
|
372
|
+
expect(capturedParams.b).toBe(b);
|
|
373
|
+
}
|
|
374
|
+
}),
|
|
375
|
+
{ numRuns: 500 },
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("emoji params are captured verbatim", () => {
|
|
380
|
+
let capturedParams: Record<string, string> = {};
|
|
381
|
+
const mock = schmock();
|
|
382
|
+
mock("GET /emoji/:val", (ctx) => {
|
|
383
|
+
capturedParams = ctx.params;
|
|
384
|
+
return { ok: true };
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
fc.assert(
|
|
388
|
+
fc.asyncProperty(emojiSeg, async (emoji) => {
|
|
389
|
+
const response = await mock.handle("GET", `/emoji/${emoji}`);
|
|
390
|
+
expect(response.status).toBe(200);
|
|
391
|
+
expect(capturedParams.val).toBe(emoji);
|
|
392
|
+
}),
|
|
393
|
+
{ numRuns: 100 },
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("mixed emoji + ASCII paths resolve without error", () => {
|
|
398
|
+
const mock = schmock();
|
|
399
|
+
mock("GET /search/:query", () => ({ results: [] }));
|
|
400
|
+
|
|
401
|
+
fc.assert(
|
|
402
|
+
fc.asyncProperty(mixedEmojiSeg, async (seg) => {
|
|
403
|
+
const response = await mock.handle("GET", `/search/${seg}`);
|
|
404
|
+
expect(response).toBeDefined();
|
|
405
|
+
expect(typeof response.status).toBe("number");
|
|
406
|
+
}),
|
|
407
|
+
{ numRuns: 200 },
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("namespace stripping handles arbitrary prefixes", () => {
|
|
412
|
+
fc.assert(
|
|
413
|
+
fc.asyncProperty(namespace, hostilePath, async (ns, reqPath) => {
|
|
414
|
+
const mock = schmock({ namespace: ns });
|
|
415
|
+
mock("GET /test", () => ({ ok: true }));
|
|
416
|
+
const response = await mock.handle("GET", reqPath);
|
|
417
|
+
expect(response).toBeDefined();
|
|
418
|
+
expect(typeof response.status).toBe("number");
|
|
419
|
+
}),
|
|
420
|
+
{ numRuns: 500 },
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("namespace + valid route resolves correctly", () => {
|
|
425
|
+
fc.assert(
|
|
426
|
+
fc.asyncProperty(safeSeg, async (ns) => {
|
|
427
|
+
const mock = schmock({ namespace: `/${ns}` });
|
|
428
|
+
mock("GET /ping", () => ({ pong: true }));
|
|
429
|
+
const response = await mock.handle("GET", `/${ns}/ping`);
|
|
430
|
+
expect(response.status).toBe(200);
|
|
431
|
+
}),
|
|
432
|
+
{ numRuns: 300 },
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("unmatched routes always return 404 with ROUTE_NOT_FOUND code", () => {
|
|
437
|
+
const mock = schmock();
|
|
438
|
+
mock("GET /only-this", () => ({ ok: true }));
|
|
439
|
+
|
|
440
|
+
// Generate paths that definitely won't match "/only-this"
|
|
441
|
+
const nonMatchingPath = fc
|
|
442
|
+
.array(safeSeg, { minLength: 2, maxLength: 5 })
|
|
443
|
+
.map((segs) => `/${segs.join("/")}`);
|
|
444
|
+
|
|
445
|
+
fc.assert(
|
|
446
|
+
fc.asyncProperty(nonMatchingPath, async (reqPath) => {
|
|
447
|
+
const response = await mock.handle("GET", reqPath);
|
|
448
|
+
expect(response.status).toBe(404);
|
|
449
|
+
expect((response.body as any).code).toBe("ROUTE_NOT_FOUND");
|
|
450
|
+
}),
|
|
451
|
+
{ numRuns: 500 },
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("regex matching terminates quickly (no ReDoS)", () => {
|
|
456
|
+
const mock = schmock();
|
|
457
|
+
mock("GET /a/:b/:c/:d/:e", () => ({ ok: true }));
|
|
458
|
+
|
|
459
|
+
const longRepeated = fc
|
|
460
|
+
.integer({ min: 100, max: 1000 })
|
|
461
|
+
.map((n) => `/${"a/".repeat(n)}z`);
|
|
462
|
+
|
|
463
|
+
fc.assert(
|
|
464
|
+
fc.asyncProperty(longRepeated, async (reqPath) => {
|
|
465
|
+
const start = performance.now();
|
|
466
|
+
const response = await mock.handle("GET", reqPath);
|
|
467
|
+
const elapsed = performance.now() - start;
|
|
468
|
+
expect(response).toBeDefined();
|
|
469
|
+
expect(elapsed).toBeLessThan(50);
|
|
470
|
+
}),
|
|
471
|
+
{ numRuns: 200 },
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("many registered routes still resolve without delay", () => {
|
|
476
|
+
const mock = schmock();
|
|
477
|
+
// Register 100 routes
|
|
478
|
+
for (let i = 0; i < 100; i++) {
|
|
479
|
+
mock(`GET /r${i}/:id` as any, () => ({ route: i }));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
fc.assert(
|
|
483
|
+
fc.asyncProperty(hostilePath, async (reqPath) => {
|
|
484
|
+
const start = performance.now();
|
|
485
|
+
const response = await mock.handle("GET", reqPath);
|
|
486
|
+
const elapsed = performance.now() - start;
|
|
487
|
+
expect(response).toBeDefined();
|
|
488
|
+
expect(elapsed).toBeLessThan(50);
|
|
489
|
+
}),
|
|
490
|
+
{ numRuns: 300 },
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
});
|
package/src/parser.ts
CHANGED
|
@@ -1,20 +1,7 @@
|
|
|
1
|
+
import { toHttpMethod } from "./constants.js";
|
|
1
2
|
import { RouteParseError } from "./errors.js";
|
|
2
3
|
import type { HttpMethod } from "./types.js";
|
|
3
4
|
|
|
4
|
-
const HTTP_METHODS: readonly HttpMethod[] = [
|
|
5
|
-
"GET",
|
|
6
|
-
"POST",
|
|
7
|
-
"PUT",
|
|
8
|
-
"DELETE",
|
|
9
|
-
"PATCH",
|
|
10
|
-
"HEAD",
|
|
11
|
-
"OPTIONS",
|
|
12
|
-
];
|
|
13
|
-
|
|
14
|
-
function isHttpMethod(method: string): method is HttpMethod {
|
|
15
|
-
return HTTP_METHODS.includes(method as HttpMethod);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
5
|
export interface ParsedRoute {
|
|
19
6
|
method: HttpMethod;
|
|
20
7
|
path: string;
|
|
@@ -66,13 +53,8 @@ export function parseRouteKey(routeKey: string): ParsedRoute {
|
|
|
66
53
|
|
|
67
54
|
const pattern = new RegExp(`^${regexPath}$`);
|
|
68
55
|
|
|
69
|
-
// The regex guarantees method is valid, but we use the type guard for type safety
|
|
70
|
-
if (!isHttpMethod(method)) {
|
|
71
|
-
throw new RouteParseError(routeKey, `Invalid HTTP method: ${method}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
56
|
return {
|
|
75
|
-
method,
|
|
57
|
+
method: toHttpMethod(method),
|
|
76
58
|
path,
|
|
77
59
|
pattern,
|
|
78
60
|
params,
|
|
@@ -420,6 +420,97 @@ describe("plugin system", () => {
|
|
|
420
420
|
});
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
+
describe("plugin install hook", () => {
|
|
424
|
+
it("calls install with callable instance when pipe() is invoked", () => {
|
|
425
|
+
const mock = schmock();
|
|
426
|
+
let receivedInstance: unknown;
|
|
427
|
+
|
|
428
|
+
const plugin: Schmock.Plugin = {
|
|
429
|
+
name: "install-test",
|
|
430
|
+
install(instance) {
|
|
431
|
+
receivedInstance = instance;
|
|
432
|
+
},
|
|
433
|
+
process: (ctx: any, res: any) => ({ context: ctx, response: res }),
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
mock.pipe(plugin);
|
|
437
|
+
expect(receivedInstance).toBe(mock);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("works normally when plugin has no install method", async () => {
|
|
441
|
+
const mock = schmock();
|
|
442
|
+
|
|
443
|
+
const plugin: Schmock.Plugin = {
|
|
444
|
+
name: "no-install",
|
|
445
|
+
process: (ctx: any, res: any) => ({ context: ctx, response: res }),
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
mock("GET /test", "hello").pipe(plugin);
|
|
449
|
+
const response = await mock.handle("GET", "/test");
|
|
450
|
+
expect(response.body).toBe("hello");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("allows install to register routes on the instance", async () => {
|
|
454
|
+
const mock = schmock();
|
|
455
|
+
|
|
456
|
+
const plugin: Schmock.Plugin = {
|
|
457
|
+
name: "route-installer",
|
|
458
|
+
install(instance) {
|
|
459
|
+
instance("GET /installed", { message: "from-install" });
|
|
460
|
+
},
|
|
461
|
+
process: (ctx: any, res: any) => ({ context: ctx, response: res }),
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
mock.pipe(plugin);
|
|
465
|
+
|
|
466
|
+
const response = await mock.handle("GET", "/installed");
|
|
467
|
+
expect(response.status).toBe(200);
|
|
468
|
+
expect(response.body).toEqual({ message: "from-install" });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("allows install to register multiple routes", async () => {
|
|
472
|
+
const mock = schmock();
|
|
473
|
+
|
|
474
|
+
const plugin: Schmock.Plugin = {
|
|
475
|
+
name: "multi-route-installer",
|
|
476
|
+
install(instance) {
|
|
477
|
+
instance("GET /a", "route-a");
|
|
478
|
+
instance("POST /b", "route-b");
|
|
479
|
+
},
|
|
480
|
+
process: (ctx: any, res: any) => ({ context: ctx, response: res }),
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
mock.pipe(plugin);
|
|
484
|
+
|
|
485
|
+
const responseA = await mock.handle("GET", "/a");
|
|
486
|
+
expect(responseA.body).toBe("route-a");
|
|
487
|
+
|
|
488
|
+
const responseB = await mock.handle("POST", "/b");
|
|
489
|
+
expect(responseB.body).toBe("route-b");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("routes registered in install work with generator functions", async () => {
|
|
493
|
+
const mock = schmock();
|
|
494
|
+
|
|
495
|
+
const plugin: Schmock.Plugin = {
|
|
496
|
+
name: "generator-installer",
|
|
497
|
+
install(instance) {
|
|
498
|
+
instance("GET /items/:id", (ctx) => ({
|
|
499
|
+
id: ctx.params.id,
|
|
500
|
+
name: "test",
|
|
501
|
+
}));
|
|
502
|
+
},
|
|
503
|
+
process: (ctx: any, res: any) => ({ context: ctx, response: res }),
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
mock.pipe(plugin);
|
|
507
|
+
|
|
508
|
+
const response = await mock.handle("GET", "/items/42");
|
|
509
|
+
expect(response.status).toBe(200);
|
|
510
|
+
expect(response.body).toEqual({ id: "42", name: "test" });
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
423
514
|
describe("debug logging", () => {
|
|
424
515
|
it("logs plugin pipeline execution with debug enabled", async () => {
|
|
425
516
|
const mock = schmock({ debug: true });
|
|
@@ -3,15 +3,14 @@ import { schmock } from "./index";
|
|
|
3
3
|
|
|
4
4
|
describe("response parsing", () => {
|
|
5
5
|
describe("tuple response formats", () => {
|
|
6
|
-
it("
|
|
6
|
+
it("treats single-element array [status] as data, not tuple", async () => {
|
|
7
7
|
const mock = schmock();
|
|
8
8
|
mock("GET /status-only", () => [204] as [number]);
|
|
9
9
|
|
|
10
10
|
const response = await mock.handle("GET", "/status-only");
|
|
11
11
|
|
|
12
|
-
expect(response.status).toBe(
|
|
13
|
-
expect(response.body).
|
|
14
|
-
expect(response.headers).toEqual({});
|
|
12
|
+
expect(response.status).toBe(200);
|
|
13
|
+
expect(response.body).toEqual([204]);
|
|
15
14
|
});
|
|
16
15
|
|
|
17
16
|
it("handles [status, body] tuple", async () => {
|
|
@@ -61,7 +60,7 @@ describe("response parsing", () => {
|
|
|
61
60
|
expect(response.headers).toEqual({});
|
|
62
61
|
});
|
|
63
62
|
|
|
64
|
-
it("
|
|
63
|
+
it("treats arrays with more than 3 elements as data, not tuple", async () => {
|
|
65
64
|
const mock = schmock();
|
|
66
65
|
mock(
|
|
67
66
|
"GET /extra",
|
|
@@ -71,8 +70,13 @@ describe("response parsing", () => {
|
|
|
71
70
|
const response = await mock.handle("GET", "/extra");
|
|
72
71
|
|
|
73
72
|
expect(response.status).toBe(200);
|
|
74
|
-
expect(response.body).
|
|
75
|
-
|
|
73
|
+
expect(response.body).toEqual([
|
|
74
|
+
200,
|
|
75
|
+
"data",
|
|
76
|
+
{},
|
|
77
|
+
"ignored",
|
|
78
|
+
"also-ignored",
|
|
79
|
+
]);
|
|
76
80
|
});
|
|
77
81
|
|
|
78
82
|
it("treats non-numeric first element as body, not status", async () => {
|