@schmock/core 1.0.4 → 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 +139 -59
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -11
- 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 +1 -1
- package/src/builder.test.ts +2 -2
- package/src/builder.ts +216 -107
- package/src/constants.ts +1 -1
- package/src/index.ts +32 -29
- package/src/namespace.test.ts +3 -2
- package/src/parser.property.test.ts +495 -0
- package/src/parser.ts +2 -20
- 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
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) {
|
|
@@ -150,6 +162,20 @@ export class CallableMockInstance {
|
|
|
150
162
|
};
|
|
151
163
|
|
|
152
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
|
+
|
|
153
179
|
this.logger.log("route", `Route defined: ${route}`, {
|
|
154
180
|
contentType: config.contentType,
|
|
155
181
|
generatorType: typeof generator,
|
|
@@ -159,7 +185,7 @@ export class CallableMockInstance {
|
|
|
159
185
|
return this;
|
|
160
186
|
}
|
|
161
187
|
|
|
162
|
-
pipe(plugin: Plugin): this {
|
|
188
|
+
pipe(plugin: Schmock.Plugin): this {
|
|
163
189
|
this.plugins.push(plugin);
|
|
164
190
|
this.logger.log(
|
|
165
191
|
"plugin",
|
|
@@ -174,12 +200,83 @@ export class CallableMockInstance {
|
|
|
174
200
|
return this;
|
|
175
201
|
}
|
|
176
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
|
+
|
|
177
274
|
async handle(
|
|
178
|
-
method: HttpMethod,
|
|
275
|
+
method: Schmock.HttpMethod,
|
|
179
276
|
path: string,
|
|
180
|
-
options?: RequestOptions,
|
|
181
|
-
): Promise<Response> {
|
|
182
|
-
const requestId =
|
|
277
|
+
options?: Schmock.RequestOptions,
|
|
278
|
+
): Promise<Schmock.Response> {
|
|
279
|
+
const requestId = crypto.randomUUID();
|
|
183
280
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
184
281
|
headers: options?.headers,
|
|
185
282
|
query: options?.query,
|
|
@@ -190,46 +287,40 @@ export class CallableMockInstance {
|
|
|
190
287
|
try {
|
|
191
288
|
// Apply namespace if configured
|
|
192
289
|
let requestPath = path;
|
|
193
|
-
if (this.globalConfig.namespace) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
normalizedNamespace.endsWith("/") && normalizedNamespace !== "/"
|
|
209
|
-
? normalizedNamespace.slice(0, -1)
|
|
210
|
-
: normalizedNamespace;
|
|
211
|
-
|
|
212
|
-
if (!normalizedPath.startsWith(finalNamespace)) {
|
|
213
|
-
this.logger.log(
|
|
214
|
-
"route",
|
|
215
|
-
`[${requestId}] Path doesn't match namespace ${namespace}`,
|
|
216
|
-
);
|
|
217
|
-
const error = new RouteNotFoundError(method, path);
|
|
218
|
-
const response = {
|
|
219
|
-
status: 404,
|
|
220
|
-
body: { error: error.message, code: error.code },
|
|
221
|
-
headers: {},
|
|
222
|
-
};
|
|
223
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
224
|
-
return response;
|
|
225
|
-
}
|
|
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
|
+
);
|
|
226
305
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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;
|
|
232
319
|
}
|
|
320
|
+
|
|
321
|
+
// Remove namespace prefix, ensuring we always start with /
|
|
322
|
+
const stripped = pathToCheck.slice(namespace.length);
|
|
323
|
+
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
233
324
|
}
|
|
234
325
|
|
|
235
326
|
// Find matching route
|
|
@@ -259,7 +350,7 @@ export class CallableMockInstance {
|
|
|
259
350
|
const params = this.extractParams(matchedRoute, requestPath);
|
|
260
351
|
|
|
261
352
|
// Generate initial response from route handler
|
|
262
|
-
const context: RequestContext = {
|
|
353
|
+
const context: Schmock.RequestContext = {
|
|
263
354
|
method,
|
|
264
355
|
path: requestPath,
|
|
265
356
|
params,
|
|
@@ -269,15 +360,15 @@ export class CallableMockInstance {
|
|
|
269
360
|
state: this.globalConfig.state || {},
|
|
270
361
|
};
|
|
271
362
|
|
|
272
|
-
let result:
|
|
273
|
-
if (
|
|
274
|
-
result = await
|
|
363
|
+
let result: unknown;
|
|
364
|
+
if (isGeneratorFunction(matchedRoute.generator)) {
|
|
365
|
+
result = await matchedRoute.generator(context);
|
|
275
366
|
} else {
|
|
276
367
|
result = matchedRoute.generator;
|
|
277
368
|
}
|
|
278
369
|
|
|
279
370
|
// Build plugin context
|
|
280
|
-
let pluginContext: PluginContext = {
|
|
371
|
+
let pluginContext: Schmock.PluginContext = {
|
|
281
372
|
path: requestPath,
|
|
282
373
|
route: matchedRoute.config,
|
|
283
374
|
method,
|
|
@@ -302,7 +393,7 @@ export class CallableMockInstance {
|
|
|
302
393
|
} catch (error) {
|
|
303
394
|
this.logger.log(
|
|
304
395
|
"error",
|
|
305
|
-
`[${requestId}] Plugin pipeline error: ${(error
|
|
396
|
+
`[${requestId}] Plugin pipeline error: ${errorMessage(error)}`,
|
|
306
397
|
);
|
|
307
398
|
throw error;
|
|
308
399
|
}
|
|
@@ -313,6 +404,18 @@ export class CallableMockInstance {
|
|
|
313
404
|
// Apply global delay if configured
|
|
314
405
|
await this.applyDelay();
|
|
315
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
|
+
|
|
316
419
|
// Log successful response
|
|
317
420
|
this.logger.log(
|
|
318
421
|
"response",
|
|
@@ -329,7 +432,7 @@ export class CallableMockInstance {
|
|
|
329
432
|
} catch (error) {
|
|
330
433
|
this.logger.log(
|
|
331
434
|
"error",
|
|
332
|
-
`[${requestId}] Error processing request: ${(error
|
|
435
|
+
`[${requestId}] Error processing request: ${errorMessage(error)}`,
|
|
333
436
|
error,
|
|
334
437
|
);
|
|
335
438
|
|
|
@@ -337,11 +440,8 @@ export class CallableMockInstance {
|
|
|
337
440
|
const errorResponse = {
|
|
338
441
|
status: 500,
|
|
339
442
|
body: {
|
|
340
|
-
error: (error
|
|
341
|
-
code:
|
|
342
|
-
error instanceof SchmockError
|
|
343
|
-
? (error as SchmockError).code
|
|
344
|
-
: "INTERNAL_ERROR",
|
|
443
|
+
error: errorMessage(error),
|
|
444
|
+
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
345
445
|
},
|
|
346
446
|
headers: {},
|
|
347
447
|
};
|
|
@@ -382,20 +482,18 @@ export class CallableMockInstance {
|
|
|
382
482
|
* @returns Normalized Response object with status, body, and headers
|
|
383
483
|
* @private
|
|
384
484
|
*/
|
|
385
|
-
private parseResponse(
|
|
485
|
+
private parseResponse(
|
|
486
|
+
result: unknown,
|
|
487
|
+
routeConfig: Schmock.RouteConfig,
|
|
488
|
+
): Schmock.Response {
|
|
386
489
|
let status = 200;
|
|
387
|
-
let body = result;
|
|
490
|
+
let body: unknown = result;
|
|
388
491
|
let headers: Record<string, string> = {};
|
|
389
492
|
|
|
390
493
|
let tupleFormat = false;
|
|
391
494
|
|
|
392
495
|
// Handle already-formed response objects (from plugin error recovery)
|
|
393
|
-
if (
|
|
394
|
-
result &&
|
|
395
|
-
typeof result === "object" &&
|
|
396
|
-
"status" in result &&
|
|
397
|
-
"body" in result
|
|
398
|
-
) {
|
|
496
|
+
if (isResponseObject(result)) {
|
|
399
497
|
return {
|
|
400
498
|
status: result.status,
|
|
401
499
|
body: result.body,
|
|
@@ -452,13 +550,13 @@ export class CallableMockInstance {
|
|
|
452
550
|
* @private
|
|
453
551
|
*/
|
|
454
552
|
private async runPluginPipeline(
|
|
455
|
-
context: PluginContext,
|
|
456
|
-
initialResponse?:
|
|
457
|
-
_routeConfig?: RouteConfig,
|
|
553
|
+
context: Schmock.PluginContext,
|
|
554
|
+
initialResponse?: unknown,
|
|
555
|
+
_routeConfig?: Schmock.RouteConfig,
|
|
458
556
|
_requestId?: string,
|
|
459
|
-
): Promise<{ context: PluginContext; response?:
|
|
557
|
+
): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
|
|
460
558
|
let currentContext = context;
|
|
461
|
-
let response:
|
|
559
|
+
let response: unknown = initialResponse;
|
|
462
560
|
|
|
463
561
|
this.logger.log(
|
|
464
562
|
"pipeline",
|
|
@@ -497,14 +595,16 @@ export class CallableMockInstance {
|
|
|
497
595
|
} catch (error) {
|
|
498
596
|
this.logger.log(
|
|
499
597
|
"pipeline",
|
|
500
|
-
`Plugin ${plugin.name} failed: ${(error
|
|
598
|
+
`Plugin ${plugin.name} failed: ${errorMessage(error)}`,
|
|
501
599
|
);
|
|
502
600
|
|
|
503
601
|
// Try error handling if plugin has onError hook
|
|
504
602
|
if (plugin.onError) {
|
|
505
603
|
try {
|
|
604
|
+
const pluginError =
|
|
605
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
506
606
|
const errorResult = await plugin.onError(
|
|
507
|
-
|
|
607
|
+
pluginError,
|
|
508
608
|
currentContext,
|
|
509
609
|
);
|
|
510
610
|
if (errorResult) {
|
|
@@ -512,26 +612,38 @@ export class CallableMockInstance {
|
|
|
512
612
|
"pipeline",
|
|
513
613
|
`Plugin ${plugin.name} handled error`,
|
|
514
614
|
);
|
|
515
|
-
|
|
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
|
|
516
622
|
if (
|
|
517
623
|
typeof errorResult === "object" &&
|
|
518
624
|
errorResult !== null &&
|
|
519
625
|
"status" in errorResult
|
|
520
626
|
) {
|
|
521
|
-
// Return the error response as the current response, stop pipeline
|
|
522
627
|
response = errorResult;
|
|
523
628
|
break;
|
|
524
629
|
}
|
|
525
630
|
}
|
|
631
|
+
// void/falsy return → propagate original error below
|
|
526
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
|
+
}
|
|
527
637
|
this.logger.log(
|
|
528
638
|
"pipeline",
|
|
529
|
-
`Plugin ${plugin.name} error handler failed: ${(hookError
|
|
639
|
+
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
530
640
|
);
|
|
531
641
|
}
|
|
532
642
|
}
|
|
533
643
|
|
|
534
|
-
|
|
644
|
+
const cause =
|
|
645
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
646
|
+
throw new PluginError(plugin.name, cause);
|
|
535
647
|
}
|
|
536
648
|
}
|
|
537
649
|
|
|
@@ -548,21 +660,18 @@ export class CallableMockInstance {
|
|
|
548
660
|
* @private
|
|
549
661
|
*/
|
|
550
662
|
private findRoute(
|
|
551
|
-
method: HttpMethod,
|
|
663
|
+
method: Schmock.HttpMethod,
|
|
552
664
|
path: string,
|
|
553
665
|
): CompiledCallableRoute | undefined {
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
) {
|
|
561
|
-
return route;
|
|
562
|
-
}
|
|
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;
|
|
563
672
|
}
|
|
564
673
|
|
|
565
|
-
//
|
|
674
|
+
// Fall through to parameterized route scan
|
|
566
675
|
for (const route of this.routes) {
|
|
567
676
|
if (
|
|
568
677
|
route.method === method &&
|
package/src/constants.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const HTTP_METHODS: readonly HttpMethod[] = [
|
|
|
13
13
|
] as const;
|
|
14
14
|
|
|
15
15
|
export function isHttpMethod(method: string): method is HttpMethod {
|
|
16
|
-
return HTTP_METHODS.includes(method
|
|
16
|
+
return (HTTP_METHODS as readonly string[]).includes(method);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function toHttpMethod(method: string): HttpMethod {
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
import { CallableMockInstance
|
|
2
|
-
import type {
|
|
3
|
-
CallableMockInstance,
|
|
4
|
-
Generator,
|
|
5
|
-
GlobalConfig,
|
|
6
|
-
Plugin,
|
|
7
|
-
RouteConfig,
|
|
8
|
-
RouteKey,
|
|
9
|
-
} from "./types.js";
|
|
1
|
+
import { CallableMockInstance } from "./builder.js";
|
|
10
2
|
|
|
11
3
|
/**
|
|
12
4
|
* Create a new Schmock mock instance with callable API.
|
|
@@ -31,28 +23,39 @@ import type {
|
|
|
31
23
|
* @param config Optional global configuration
|
|
32
24
|
* @returns A callable mock instance
|
|
33
25
|
*/
|
|
34
|
-
export function schmock(
|
|
26
|
+
export function schmock(
|
|
27
|
+
config?: Schmock.GlobalConfig,
|
|
28
|
+
): Schmock.CallableMockInstance {
|
|
35
29
|
// Always use new callable API
|
|
36
|
-
const instance = new
|
|
30
|
+
const instance = new CallableMockInstance(config || {});
|
|
37
31
|
|
|
38
|
-
//
|
|
39
|
-
const callableInstance = (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
// Callable proxy: a function with attached methods
|
|
33
|
+
const callableInstance: Schmock.CallableMockInstance = Object.assign(
|
|
34
|
+
(
|
|
35
|
+
route: Schmock.RouteKey,
|
|
36
|
+
generator: Schmock.Generator,
|
|
37
|
+
routeConfig: Schmock.RouteConfig = {},
|
|
38
|
+
) => {
|
|
39
|
+
instance.defineRoute(route, generator, routeConfig);
|
|
40
|
+
return callableInstance;
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pipe: (plugin: Schmock.Plugin) => {
|
|
44
|
+
instance.pipe(plugin);
|
|
45
|
+
return callableInstance;
|
|
46
|
+
},
|
|
47
|
+
handle: instance.handle.bind(instance),
|
|
48
|
+
history: instance.history.bind(instance),
|
|
49
|
+
called: instance.called.bind(instance),
|
|
50
|
+
callCount: instance.callCount.bind(instance),
|
|
51
|
+
lastRequest: instance.lastRequest.bind(instance),
|
|
52
|
+
reset: instance.reset.bind(instance),
|
|
53
|
+
resetHistory: instance.resetHistory.bind(instance),
|
|
54
|
+
resetState: instance.resetState.bind(instance),
|
|
55
|
+
},
|
|
56
|
+
);
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
callableInstance.pipe = (plugin: Plugin) => {
|
|
50
|
-
instance.pipe(plugin);
|
|
51
|
-
return callableInstance; // Return callable function for chaining
|
|
52
|
-
};
|
|
53
|
-
callableInstance.handle = instance.handle.bind(instance);
|
|
54
|
-
|
|
55
|
-
return callableInstance as CallableMockInstance;
|
|
58
|
+
return callableInstance;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
// Re-export constants and utilities
|
|
@@ -74,7 +77,6 @@ export {
|
|
|
74
77
|
SchemaValidationError,
|
|
75
78
|
SchmockError,
|
|
76
79
|
} from "./errors.js";
|
|
77
|
-
|
|
78
80
|
// Re-export types
|
|
79
81
|
export type {
|
|
80
82
|
CallableMockInstance,
|
|
@@ -87,6 +89,7 @@ export type {
|
|
|
87
89
|
PluginResult,
|
|
88
90
|
RequestContext,
|
|
89
91
|
RequestOptions,
|
|
92
|
+
RequestRecord,
|
|
90
93
|
Response,
|
|
91
94
|
ResponseBody,
|
|
92
95
|
ResponseResult,
|
package/src/namespace.test.ts
CHANGED
|
@@ -95,8 +95,9 @@ describe("namespace functionality", () => {
|
|
|
95
95
|
const response2 = await mock.handle("GET", "/api//users");
|
|
96
96
|
|
|
97
97
|
expect(response1.body).toBe("users");
|
|
98
|
-
//
|
|
99
|
-
expect(response2.status).toBe(
|
|
98
|
+
// New logic gracefully handles double slashes by stripping the full namespace
|
|
99
|
+
expect(response2.status).toBe(200);
|
|
100
|
+
expect(response2.body).toBe("users");
|
|
100
101
|
});
|
|
101
102
|
|
|
102
103
|
it("handles empty namespace", async () => {
|