@schmock/core 1.0.3 → 1.1.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/builder.d.ts +13 -5
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +147 -60
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +20 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -11
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +2 -17
- package/dist/types.d.ts +17 -210
- 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 +232 -108
- package/src/constants.test.ts +59 -0
- package/src/constants.ts +25 -0
- package/src/errors.ts +3 -1
- package/src/index.ts +41 -29
- package/src/namespace.test.ts +3 -2
- package/src/parser.property.test.ts +495 -0
- package/src/parser.ts +2 -20
- package/src/route-matching.test.ts +1 -1
- 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 +110 -94
- package/src/steps/error-handling.steps.ts +90 -66
- 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 -259
package/src/builder.ts
CHANGED
|
@@ -5,19 +5,10 @@ import {
|
|
|
5
5
|
SchmockError,
|
|
6
6
|
} from "./errors.js";
|
|
7
7
|
import { parseRouteKey } from "./parser.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
HttpMethod,
|
|
13
|
-
Plugin,
|
|
14
|
-
PluginContext,
|
|
15
|
-
RequestContext,
|
|
16
|
-
RequestOptions,
|
|
17
|
-
Response,
|
|
18
|
-
RouteConfig,
|
|
19
|
-
RouteKey,
|
|
20
|
-
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
function errorMessage(error: unknown): string {
|
|
10
|
+
return error instanceof Error ? error.message : "Unknown error";
|
|
11
|
+
}
|
|
21
12
|
|
|
22
13
|
/**
|
|
23
14
|
* Debug logger that respects debug mode configuration
|
|
@@ -25,7 +16,7 @@ import type {
|
|
|
25
16
|
class DebugLogger {
|
|
26
17
|
constructor(private enabled = false) {}
|
|
27
18
|
|
|
28
|
-
log(category: string, message: string, data?:
|
|
19
|
+
log(category: string, message: string, data?: unknown) {
|
|
29
20
|
if (!this.enabled) return;
|
|
30
21
|
|
|
31
22
|
const timestamp = new Date().toISOString();
|
|
@@ -55,10 +46,29 @@ class DebugLogger {
|
|
|
55
46
|
interface CompiledCallableRoute {
|
|
56
47
|
pattern: RegExp;
|
|
57
48
|
params: string[];
|
|
58
|
-
method: HttpMethod;
|
|
49
|
+
method: Schmock.HttpMethod;
|
|
59
50
|
path: string;
|
|
60
|
-
generator: Generator;
|
|
61
|
-
config: RouteConfig;
|
|
51
|
+
generator: Schmock.Generator;
|
|
52
|
+
config: Schmock.RouteConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isGeneratorFunction(
|
|
56
|
+
gen: Schmock.Generator,
|
|
57
|
+
): gen is Schmock.GeneratorFunction {
|
|
58
|
+
return typeof gen === "function";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isResponseObject(value: unknown): value is {
|
|
62
|
+
status: number;
|
|
63
|
+
body: unknown;
|
|
64
|
+
headers?: Record<string, string>;
|
|
65
|
+
} {
|
|
66
|
+
return (
|
|
67
|
+
typeof value === "object" &&
|
|
68
|
+
value !== null &&
|
|
69
|
+
"status" in value &&
|
|
70
|
+
"body" in value
|
|
71
|
+
);
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
/**
|
|
@@ -68,10 +78,12 @@ interface CompiledCallableRoute {
|
|
|
68
78
|
*/
|
|
69
79
|
export class CallableMockInstance {
|
|
70
80
|
private routes: CompiledCallableRoute[] = [];
|
|
71
|
-
private
|
|
81
|
+
private staticRoutes = new Map<string, CompiledCallableRoute>();
|
|
82
|
+
private plugins: Schmock.Plugin[] = [];
|
|
72
83
|
private logger: DebugLogger;
|
|
84
|
+
private requestHistory: Schmock.RequestRecord[] = [];
|
|
73
85
|
|
|
74
|
-
constructor(private globalConfig: GlobalConfig = {}) {
|
|
86
|
+
constructor(private globalConfig: Schmock.GlobalConfig = {}) {
|
|
75
87
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
76
88
|
if (globalConfig.debug) {
|
|
77
89
|
this.logger.log("config", "Debug mode enabled");
|
|
@@ -85,9 +97,9 @@ export class CallableMockInstance {
|
|
|
85
97
|
|
|
86
98
|
// Method for defining routes (called when instance is invoked)
|
|
87
99
|
defineRoute(
|
|
88
|
-
route: RouteKey,
|
|
89
|
-
generator: Generator,
|
|
90
|
-
config: RouteConfig,
|
|
100
|
+
route: Schmock.RouteKey,
|
|
101
|
+
generator: Schmock.Generator,
|
|
102
|
+
config: Schmock.RouteConfig,
|
|
91
103
|
): this {
|
|
92
104
|
// Auto-detect contentType if not provided
|
|
93
105
|
if (!config.contentType) {
|
|
@@ -128,6 +140,17 @@ export class CallableMockInstance {
|
|
|
128
140
|
// Parse the route key to create pattern and extract parameters
|
|
129
141
|
const parsed = parseRouteKey(route);
|
|
130
142
|
|
|
143
|
+
// Check for duplicate routes
|
|
144
|
+
const existing = this.routes.find(
|
|
145
|
+
(r) => r.method === parsed.method && r.path === parsed.path,
|
|
146
|
+
);
|
|
147
|
+
if (existing) {
|
|
148
|
+
this.logger.log(
|
|
149
|
+
"warning",
|
|
150
|
+
`Duplicate route: ${route} — first registration wins`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
// Compile the route
|
|
132
155
|
const compiledRoute: CompiledCallableRoute = {
|
|
133
156
|
pattern: parsed.pattern,
|
|
@@ -139,6 +162,20 @@ export class CallableMockInstance {
|
|
|
139
162
|
};
|
|
140
163
|
|
|
141
164
|
this.routes.push(compiledRoute);
|
|
165
|
+
|
|
166
|
+
// Store static routes (no params) in Map for O(1) lookup
|
|
167
|
+
// Only store the first registration — "first registration wins" semantics
|
|
168
|
+
if (parsed.params.length === 0) {
|
|
169
|
+
const normalizedPath =
|
|
170
|
+
parsed.path.endsWith("/") && parsed.path !== "/"
|
|
171
|
+
? parsed.path.slice(0, -1)
|
|
172
|
+
: parsed.path;
|
|
173
|
+
const key = `${parsed.method} ${normalizedPath}`;
|
|
174
|
+
if (!this.staticRoutes.has(key)) {
|
|
175
|
+
this.staticRoutes.set(key, compiledRoute);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
142
179
|
this.logger.log("route", `Route defined: ${route}`, {
|
|
143
180
|
contentType: config.contentType,
|
|
144
181
|
generatorType: typeof generator,
|
|
@@ -148,7 +185,7 @@ export class CallableMockInstance {
|
|
|
148
185
|
return this;
|
|
149
186
|
}
|
|
150
187
|
|
|
151
|
-
pipe(plugin: Plugin): this {
|
|
188
|
+
pipe(plugin: Schmock.Plugin): this {
|
|
152
189
|
this.plugins.push(plugin);
|
|
153
190
|
this.logger.log(
|
|
154
191
|
"plugin",
|
|
@@ -163,12 +200,83 @@ export class CallableMockInstance {
|
|
|
163
200
|
return this;
|
|
164
201
|
}
|
|
165
202
|
|
|
203
|
+
// ===== Request Spy / History API =====
|
|
204
|
+
|
|
205
|
+
history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
|
|
206
|
+
if (method && path) {
|
|
207
|
+
return this.requestHistory.filter(
|
|
208
|
+
(r) => r.method === method && r.path === path,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return [...this.requestHistory];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
called(method?: Schmock.HttpMethod, path?: string): boolean {
|
|
215
|
+
if (method && path) {
|
|
216
|
+
return this.requestHistory.some(
|
|
217
|
+
(r) => r.method === method && r.path === path,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return this.requestHistory.length > 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
callCount(method?: Schmock.HttpMethod, path?: string): number {
|
|
224
|
+
if (method && path) {
|
|
225
|
+
return this.requestHistory.filter(
|
|
226
|
+
(r) => r.method === method && r.path === path,
|
|
227
|
+
).length;
|
|
228
|
+
}
|
|
229
|
+
return this.requestHistory.length;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
lastRequest(
|
|
233
|
+
method?: Schmock.HttpMethod,
|
|
234
|
+
path?: string,
|
|
235
|
+
): Schmock.RequestRecord | undefined {
|
|
236
|
+
if (method && path) {
|
|
237
|
+
const filtered = this.requestHistory.filter(
|
|
238
|
+
(r) => r.method === method && r.path === path,
|
|
239
|
+
);
|
|
240
|
+
return filtered[filtered.length - 1];
|
|
241
|
+
}
|
|
242
|
+
return this.requestHistory[this.requestHistory.length - 1];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ===== Reset / Lifecycle =====
|
|
246
|
+
|
|
247
|
+
reset(): void {
|
|
248
|
+
this.routes = [];
|
|
249
|
+
this.staticRoutes.clear();
|
|
250
|
+
this.plugins = [];
|
|
251
|
+
this.requestHistory = [];
|
|
252
|
+
if (this.globalConfig.state) {
|
|
253
|
+
for (const key of Object.keys(this.globalConfig.state)) {
|
|
254
|
+
delete this.globalConfig.state[key];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
this.logger.log("lifecycle", "Mock fully reset");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
resetHistory(): void {
|
|
261
|
+
this.requestHistory = [];
|
|
262
|
+
this.logger.log("lifecycle", "Request history cleared");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
resetState(): void {
|
|
266
|
+
if (this.globalConfig.state) {
|
|
267
|
+
for (const key of Object.keys(this.globalConfig.state)) {
|
|
268
|
+
delete this.globalConfig.state[key];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.logger.log("lifecycle", "State cleared");
|
|
272
|
+
}
|
|
273
|
+
|
|
166
274
|
async handle(
|
|
167
|
-
method: HttpMethod,
|
|
275
|
+
method: Schmock.HttpMethod,
|
|
168
276
|
path: string,
|
|
169
|
-
options?: RequestOptions,
|
|
170
|
-
): Promise<Response> {
|
|
171
|
-
const requestId =
|
|
277
|
+
options?: Schmock.RequestOptions,
|
|
278
|
+
): Promise<Schmock.Response> {
|
|
279
|
+
const requestId = crypto.randomUUID();
|
|
172
280
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
173
281
|
headers: options?.headers,
|
|
174
282
|
query: options?.query,
|
|
@@ -179,46 +287,40 @@ export class CallableMockInstance {
|
|
|
179
287
|
try {
|
|
180
288
|
// Apply namespace if configured
|
|
181
289
|
let requestPath = path;
|
|
182
|
-
if (this.globalConfig.namespace) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
normalizedNamespace.endsWith("/") && normalizedNamespace !== "/"
|
|
198
|
-
? normalizedNamespace.slice(0, -1)
|
|
199
|
-
: normalizedNamespace;
|
|
200
|
-
|
|
201
|
-
if (!normalizedPath.startsWith(finalNamespace)) {
|
|
202
|
-
this.logger.log(
|
|
203
|
-
"route",
|
|
204
|
-
`[${requestId}] Path doesn't match namespace ${namespace}`,
|
|
205
|
-
);
|
|
206
|
-
const error = new RouteNotFoundError(method, path);
|
|
207
|
-
const response = {
|
|
208
|
-
status: 404,
|
|
209
|
-
body: { error: error.message, code: error.code },
|
|
210
|
-
headers: {},
|
|
211
|
-
};
|
|
212
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
213
|
-
return response;
|
|
214
|
-
}
|
|
290
|
+
if (this.globalConfig.namespace && this.globalConfig.namespace !== "/") {
|
|
291
|
+
const namespace = this.globalConfig.namespace.startsWith("/")
|
|
292
|
+
? this.globalConfig.namespace
|
|
293
|
+
: `/${this.globalConfig.namespace}`;
|
|
294
|
+
|
|
295
|
+
const pathToCheck = path.startsWith("/") ? path : `/${path}`;
|
|
296
|
+
|
|
297
|
+
// Check if path starts with namespace
|
|
298
|
+
// handle both "/api/users" (starts with /api) and "/api" (exact match)
|
|
299
|
+
// but NOT "/apiv2" (prefix match but wrong segment)
|
|
300
|
+
const isMatch =
|
|
301
|
+
pathToCheck === namespace ||
|
|
302
|
+
pathToCheck.startsWith(
|
|
303
|
+
namespace.endsWith("/") ? namespace : `${namespace}/`,
|
|
304
|
+
);
|
|
215
305
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
306
|
+
if (!isMatch) {
|
|
307
|
+
this.logger.log(
|
|
308
|
+
"route",
|
|
309
|
+
`[${requestId}] Path doesn't match namespace ${namespace}`,
|
|
310
|
+
);
|
|
311
|
+
const error = new RouteNotFoundError(method, path);
|
|
312
|
+
const response = {
|
|
313
|
+
status: 404,
|
|
314
|
+
body: { error: error.message, code: error.code },
|
|
315
|
+
headers: {},
|
|
316
|
+
};
|
|
317
|
+
this.logger.timeEnd(`request-${requestId}`);
|
|
318
|
+
return response;
|
|
221
319
|
}
|
|
320
|
+
|
|
321
|
+
// Remove namespace prefix, ensuring we always start with /
|
|
322
|
+
const stripped = pathToCheck.slice(namespace.length);
|
|
323
|
+
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
222
324
|
}
|
|
223
325
|
|
|
224
326
|
// Find matching route
|
|
@@ -248,7 +350,7 @@ export class CallableMockInstance {
|
|
|
248
350
|
const params = this.extractParams(matchedRoute, requestPath);
|
|
249
351
|
|
|
250
352
|
// Generate initial response from route handler
|
|
251
|
-
const context: RequestContext = {
|
|
353
|
+
const context: Schmock.RequestContext = {
|
|
252
354
|
method,
|
|
253
355
|
path: requestPath,
|
|
254
356
|
params,
|
|
@@ -258,15 +360,15 @@ export class CallableMockInstance {
|
|
|
258
360
|
state: this.globalConfig.state || {},
|
|
259
361
|
};
|
|
260
362
|
|
|
261
|
-
let result:
|
|
262
|
-
if (
|
|
263
|
-
result = await
|
|
363
|
+
let result: unknown;
|
|
364
|
+
if (isGeneratorFunction(matchedRoute.generator)) {
|
|
365
|
+
result = await matchedRoute.generator(context);
|
|
264
366
|
} else {
|
|
265
367
|
result = matchedRoute.generator;
|
|
266
368
|
}
|
|
267
369
|
|
|
268
370
|
// Build plugin context
|
|
269
|
-
let pluginContext: PluginContext = {
|
|
371
|
+
let pluginContext: Schmock.PluginContext = {
|
|
270
372
|
path: requestPath,
|
|
271
373
|
route: matchedRoute.config,
|
|
272
374
|
method,
|
|
@@ -291,7 +393,7 @@ export class CallableMockInstance {
|
|
|
291
393
|
} catch (error) {
|
|
292
394
|
this.logger.log(
|
|
293
395
|
"error",
|
|
294
|
-
`[${requestId}] Plugin pipeline error: ${(error
|
|
396
|
+
`[${requestId}] Plugin pipeline error: ${errorMessage(error)}`,
|
|
295
397
|
);
|
|
296
398
|
throw error;
|
|
297
399
|
}
|
|
@@ -302,6 +404,18 @@ export class CallableMockInstance {
|
|
|
302
404
|
// Apply global delay if configured
|
|
303
405
|
await this.applyDelay();
|
|
304
406
|
|
|
407
|
+
// Record request in history
|
|
408
|
+
this.requestHistory.push({
|
|
409
|
+
method,
|
|
410
|
+
path: requestPath,
|
|
411
|
+
params,
|
|
412
|
+
query: options?.query || {},
|
|
413
|
+
headers: options?.headers || {},
|
|
414
|
+
body: options?.body,
|
|
415
|
+
timestamp: Date.now(),
|
|
416
|
+
response: { status: response.status, body: response.body },
|
|
417
|
+
});
|
|
418
|
+
|
|
305
419
|
// Log successful response
|
|
306
420
|
this.logger.log(
|
|
307
421
|
"response",
|
|
@@ -318,7 +432,7 @@ export class CallableMockInstance {
|
|
|
318
432
|
} catch (error) {
|
|
319
433
|
this.logger.log(
|
|
320
434
|
"error",
|
|
321
|
-
`[${requestId}] Error processing request: ${(error
|
|
435
|
+
`[${requestId}] Error processing request: ${errorMessage(error)}`,
|
|
322
436
|
error,
|
|
323
437
|
);
|
|
324
438
|
|
|
@@ -326,11 +440,8 @@ export class CallableMockInstance {
|
|
|
326
440
|
const errorResponse = {
|
|
327
441
|
status: 500,
|
|
328
442
|
body: {
|
|
329
|
-
error: (error
|
|
330
|
-
code:
|
|
331
|
-
error instanceof SchmockError
|
|
332
|
-
? (error as SchmockError).code
|
|
333
|
-
: "INTERNAL_ERROR",
|
|
443
|
+
error: errorMessage(error),
|
|
444
|
+
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
334
445
|
},
|
|
335
446
|
headers: {},
|
|
336
447
|
};
|
|
@@ -371,20 +482,18 @@ export class CallableMockInstance {
|
|
|
371
482
|
* @returns Normalized Response object with status, body, and headers
|
|
372
483
|
* @private
|
|
373
484
|
*/
|
|
374
|
-
private parseResponse(
|
|
485
|
+
private parseResponse(
|
|
486
|
+
result: unknown,
|
|
487
|
+
routeConfig: Schmock.RouteConfig,
|
|
488
|
+
): Schmock.Response {
|
|
375
489
|
let status = 200;
|
|
376
|
-
let body = result;
|
|
490
|
+
let body: unknown = result;
|
|
377
491
|
let headers: Record<string, string> = {};
|
|
378
492
|
|
|
379
493
|
let tupleFormat = false;
|
|
380
494
|
|
|
381
495
|
// Handle already-formed response objects (from plugin error recovery)
|
|
382
|
-
if (
|
|
383
|
-
result &&
|
|
384
|
-
typeof result === "object" &&
|
|
385
|
-
"status" in result &&
|
|
386
|
-
"body" in result
|
|
387
|
-
) {
|
|
496
|
+
if (isResponseObject(result)) {
|
|
388
497
|
return {
|
|
389
498
|
status: result.status,
|
|
390
499
|
body: result.body,
|
|
@@ -441,13 +550,13 @@ export class CallableMockInstance {
|
|
|
441
550
|
* @private
|
|
442
551
|
*/
|
|
443
552
|
private async runPluginPipeline(
|
|
444
|
-
context: PluginContext,
|
|
445
|
-
initialResponse?:
|
|
446
|
-
_routeConfig?: RouteConfig,
|
|
553
|
+
context: Schmock.PluginContext,
|
|
554
|
+
initialResponse?: unknown,
|
|
555
|
+
_routeConfig?: Schmock.RouteConfig,
|
|
447
556
|
_requestId?: string,
|
|
448
|
-
): Promise<{ context: PluginContext; response?:
|
|
557
|
+
): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
|
|
449
558
|
let currentContext = context;
|
|
450
|
-
let response:
|
|
559
|
+
let response: unknown = initialResponse;
|
|
451
560
|
|
|
452
561
|
this.logger.log(
|
|
453
562
|
"pipeline",
|
|
@@ -486,14 +595,16 @@ export class CallableMockInstance {
|
|
|
486
595
|
} catch (error) {
|
|
487
596
|
this.logger.log(
|
|
488
597
|
"pipeline",
|
|
489
|
-
`Plugin ${plugin.name} failed: ${(error
|
|
598
|
+
`Plugin ${plugin.name} failed: ${errorMessage(error)}`,
|
|
490
599
|
);
|
|
491
600
|
|
|
492
601
|
// Try error handling if plugin has onError hook
|
|
493
602
|
if (plugin.onError) {
|
|
494
603
|
try {
|
|
604
|
+
const pluginError =
|
|
605
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
495
606
|
const errorResult = await plugin.onError(
|
|
496
|
-
|
|
607
|
+
pluginError,
|
|
497
608
|
currentContext,
|
|
498
609
|
);
|
|
499
610
|
if (errorResult) {
|
|
@@ -501,22 +612,38 @@ export class CallableMockInstance {
|
|
|
501
612
|
"pipeline",
|
|
502
613
|
`Plugin ${plugin.name} handled error`,
|
|
503
614
|
);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
615
|
+
|
|
616
|
+
// Error return → transform the thrown error
|
|
617
|
+
if (errorResult instanceof Error) {
|
|
618
|
+
throw new PluginError(plugin.name, errorResult);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ResponseResult return → recover, stop pipeline
|
|
622
|
+
if (
|
|
623
|
+
typeof errorResult === "object" &&
|
|
624
|
+
errorResult !== null &&
|
|
625
|
+
"status" in errorResult
|
|
626
|
+
) {
|
|
507
627
|
response = errorResult;
|
|
508
628
|
break;
|
|
509
629
|
}
|
|
510
630
|
}
|
|
631
|
+
// void/falsy return → propagate original error below
|
|
511
632
|
} catch (hookError) {
|
|
633
|
+
// If the hook itself threw (including our PluginError above), re-throw it
|
|
634
|
+
if (hookError instanceof PluginError) {
|
|
635
|
+
throw hookError;
|
|
636
|
+
}
|
|
512
637
|
this.logger.log(
|
|
513
638
|
"pipeline",
|
|
514
|
-
`Plugin ${plugin.name} error handler failed: ${(hookError
|
|
639
|
+
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
515
640
|
);
|
|
516
641
|
}
|
|
517
642
|
}
|
|
518
643
|
|
|
519
|
-
|
|
644
|
+
const cause =
|
|
645
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
646
|
+
throw new PluginError(plugin.name, cause);
|
|
520
647
|
}
|
|
521
648
|
}
|
|
522
649
|
|
|
@@ -533,21 +660,18 @@ export class CallableMockInstance {
|
|
|
533
660
|
* @private
|
|
534
661
|
*/
|
|
535
662
|
private findRoute(
|
|
536
|
-
method: HttpMethod,
|
|
663
|
+
method: Schmock.HttpMethod,
|
|
537
664
|
path: string,
|
|
538
665
|
): CompiledCallableRoute | undefined {
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
) {
|
|
546
|
-
return route;
|
|
547
|
-
}
|
|
666
|
+
// O(1) lookup for static routes
|
|
667
|
+
const normalizedPath =
|
|
668
|
+
path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
669
|
+
const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
|
|
670
|
+
if (staticMatch) {
|
|
671
|
+
return staticMatch;
|
|
548
672
|
}
|
|
549
673
|
|
|
550
|
-
//
|
|
674
|
+
// Fall through to parameterized route scan
|
|
551
675
|
for (const route of this.routes) {
|
|
552
676
|
if (
|
|
553
677
|
route.method === method &&
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
HTTP_METHODS,
|
|
4
|
+
isHttpMethod,
|
|
5
|
+
ROUTE_NOT_FOUND_CODE,
|
|
6
|
+
toHttpMethod,
|
|
7
|
+
} from "./constants";
|
|
8
|
+
|
|
9
|
+
describe("constants", () => {
|
|
10
|
+
it("exports ROUTE_NOT_FOUND_CODE", () => {
|
|
11
|
+
expect(ROUTE_NOT_FOUND_CODE).toBe("ROUTE_NOT_FOUND");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("exports all HTTP methods", () => {
|
|
15
|
+
expect(HTTP_METHODS).toEqual([
|
|
16
|
+
"GET",
|
|
17
|
+
"POST",
|
|
18
|
+
"PUT",
|
|
19
|
+
"DELETE",
|
|
20
|
+
"PATCH",
|
|
21
|
+
"HEAD",
|
|
22
|
+
"OPTIONS",
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("isHttpMethod", () => {
|
|
28
|
+
it("returns true for valid HTTP methods", () => {
|
|
29
|
+
for (const method of HTTP_METHODS) {
|
|
30
|
+
expect(isHttpMethod(method)).toBe(true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns false for invalid methods", () => {
|
|
35
|
+
expect(isHttpMethod("INVALID")).toBe(false);
|
|
36
|
+
expect(isHttpMethod("")).toBe(false);
|
|
37
|
+
expect(isHttpMethod("get")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("toHttpMethod", () => {
|
|
42
|
+
it("converts lowercase to uppercase", () => {
|
|
43
|
+
expect(toHttpMethod("get")).toBe("GET");
|
|
44
|
+
expect(toHttpMethod("post")).toBe("POST");
|
|
45
|
+
expect(toHttpMethod("delete")).toBe("DELETE");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns already uppercase methods", () => {
|
|
49
|
+
expect(toHttpMethod("GET")).toBe("GET");
|
|
50
|
+
expect(toHttpMethod("PATCH")).toBe("PATCH");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws for invalid methods", () => {
|
|
54
|
+
expect(() => toHttpMethod("INVALID")).toThrow(
|
|
55
|
+
'Invalid HTTP method: "INVALID"',
|
|
56
|
+
);
|
|
57
|
+
expect(() => toHttpMethod("")).toThrow('Invalid HTTP method: ""');
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HttpMethod } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const ROUTE_NOT_FOUND_CODE = "ROUTE_NOT_FOUND" as const;
|
|
4
|
+
|
|
5
|
+
export const HTTP_METHODS: readonly HttpMethod[] = [
|
|
6
|
+
"GET",
|
|
7
|
+
"POST",
|
|
8
|
+
"PUT",
|
|
9
|
+
"DELETE",
|
|
10
|
+
"PATCH",
|
|
11
|
+
"HEAD",
|
|
12
|
+
"OPTIONS",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export function isHttpMethod(method: string): method is HttpMethod {
|
|
16
|
+
return (HTTP_METHODS as readonly string[]).includes(method);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toHttpMethod(method: string): HttpMethod {
|
|
20
|
+
const upper = method.toUpperCase();
|
|
21
|
+
if (!isHttpMethod(upper)) {
|
|
22
|
+
throw new Error(`Invalid HTTP method: "${method}"`);
|
|
23
|
+
}
|
|
24
|
+
return upper;
|
|
25
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -9,7 +9,9 @@ export class SchmockError extends Error {
|
|
|
9
9
|
) {
|
|
10
10
|
super(message);
|
|
11
11
|
this.name = "SchmockError";
|
|
12
|
-
Error.captureStackTrace
|
|
12
|
+
if (typeof Error.captureStackTrace === "function") {
|
|
13
|
+
Error.captureStackTrace(this, this.constructor);
|
|
14
|
+
}
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
|