@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
package/src/builder.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStatusTuple } from "./constants.js";
|
|
1
2
|
import {
|
|
2
3
|
PluginError,
|
|
3
4
|
RouteDefinitionError,
|
|
@@ -5,19 +6,10 @@ import {
|
|
|
5
6
|
SchmockError,
|
|
6
7
|
} from "./errors.js";
|
|
7
8
|
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";
|
|
9
|
+
|
|
10
|
+
function errorMessage(error: unknown): string {
|
|
11
|
+
return error instanceof Error ? error.message : "Unknown error";
|
|
12
|
+
}
|
|
21
13
|
|
|
22
14
|
/**
|
|
23
15
|
* Debug logger that respects debug mode configuration
|
|
@@ -25,7 +17,7 @@ import type {
|
|
|
25
17
|
class DebugLogger {
|
|
26
18
|
constructor(private enabled = false) {}
|
|
27
19
|
|
|
28
|
-
log(category: string, message: string, data?:
|
|
20
|
+
log(category: string, message: string, data?: unknown) {
|
|
29
21
|
if (!this.enabled) return;
|
|
30
22
|
|
|
31
23
|
const timestamp = new Date().toISOString();
|
|
@@ -55,10 +47,29 @@ class DebugLogger {
|
|
|
55
47
|
interface CompiledCallableRoute {
|
|
56
48
|
pattern: RegExp;
|
|
57
49
|
params: string[];
|
|
58
|
-
method: HttpMethod;
|
|
50
|
+
method: Schmock.HttpMethod;
|
|
59
51
|
path: string;
|
|
60
|
-
generator: Generator;
|
|
61
|
-
config: RouteConfig;
|
|
52
|
+
generator: Schmock.Generator;
|
|
53
|
+
config: Schmock.RouteConfig;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isGeneratorFunction(
|
|
57
|
+
gen: Schmock.Generator,
|
|
58
|
+
): gen is Schmock.GeneratorFunction {
|
|
59
|
+
return typeof gen === "function";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isResponseObject(value: unknown): value is {
|
|
63
|
+
status: number;
|
|
64
|
+
body: unknown;
|
|
65
|
+
headers?: Record<string, string>;
|
|
66
|
+
} {
|
|
67
|
+
return (
|
|
68
|
+
typeof value === "object" &&
|
|
69
|
+
value !== null &&
|
|
70
|
+
"status" in value &&
|
|
71
|
+
"body" in value
|
|
72
|
+
);
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
|
@@ -68,10 +79,13 @@ interface CompiledCallableRoute {
|
|
|
68
79
|
*/
|
|
69
80
|
export class CallableMockInstance {
|
|
70
81
|
private routes: CompiledCallableRoute[] = [];
|
|
71
|
-
private
|
|
82
|
+
private staticRoutes = new Map<string, CompiledCallableRoute>();
|
|
83
|
+
private plugins: Schmock.Plugin[] = [];
|
|
72
84
|
private logger: DebugLogger;
|
|
85
|
+
private requestHistory: Schmock.RequestRecord[] = [];
|
|
86
|
+
private callableRef: Schmock.CallableMockInstance | undefined;
|
|
73
87
|
|
|
74
|
-
constructor(private globalConfig: GlobalConfig = {}) {
|
|
88
|
+
constructor(private globalConfig: Schmock.GlobalConfig = {}) {
|
|
75
89
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
76
90
|
if (globalConfig.debug) {
|
|
77
91
|
this.logger.log("config", "Debug mode enabled");
|
|
@@ -85,9 +99,9 @@ export class CallableMockInstance {
|
|
|
85
99
|
|
|
86
100
|
// Method for defining routes (called when instance is invoked)
|
|
87
101
|
defineRoute(
|
|
88
|
-
route: RouteKey,
|
|
89
|
-
generator: Generator,
|
|
90
|
-
config: RouteConfig,
|
|
102
|
+
route: Schmock.RouteKey,
|
|
103
|
+
generator: Schmock.Generator,
|
|
104
|
+
config: Schmock.RouteConfig,
|
|
91
105
|
): this {
|
|
92
106
|
// Auto-detect contentType if not provided
|
|
93
107
|
if (!config.contentType) {
|
|
@@ -150,6 +164,20 @@ export class CallableMockInstance {
|
|
|
150
164
|
};
|
|
151
165
|
|
|
152
166
|
this.routes.push(compiledRoute);
|
|
167
|
+
|
|
168
|
+
// Store static routes (no params) in Map for O(1) lookup
|
|
169
|
+
// Only store the first registration — "first registration wins" semantics
|
|
170
|
+
if (parsed.params.length === 0) {
|
|
171
|
+
const normalizedPath =
|
|
172
|
+
parsed.path.endsWith("/") && parsed.path !== "/"
|
|
173
|
+
? parsed.path.slice(0, -1)
|
|
174
|
+
: parsed.path;
|
|
175
|
+
const key = `${parsed.method} ${normalizedPath}`;
|
|
176
|
+
if (!this.staticRoutes.has(key)) {
|
|
177
|
+
this.staticRoutes.set(key, compiledRoute);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
153
181
|
this.logger.log("route", `Route defined: ${route}`, {
|
|
154
182
|
contentType: config.contentType,
|
|
155
183
|
generatorType: typeof generator,
|
|
@@ -159,7 +187,11 @@ export class CallableMockInstance {
|
|
|
159
187
|
return this;
|
|
160
188
|
}
|
|
161
189
|
|
|
162
|
-
|
|
190
|
+
setCallableRef(ref: Schmock.CallableMockInstance): void {
|
|
191
|
+
this.callableRef = ref;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
pipe(plugin: Schmock.Plugin): this {
|
|
163
195
|
this.plugins.push(plugin);
|
|
164
196
|
this.logger.log(
|
|
165
197
|
"plugin",
|
|
@@ -171,15 +203,89 @@ export class CallableMockInstance {
|
|
|
171
203
|
hasOnError: typeof plugin.onError === "function",
|
|
172
204
|
},
|
|
173
205
|
);
|
|
206
|
+
if (plugin.install && this.callableRef) {
|
|
207
|
+
plugin.install(this.callableRef);
|
|
208
|
+
}
|
|
174
209
|
return this;
|
|
175
210
|
}
|
|
176
211
|
|
|
212
|
+
// ===== Request Spy / History API =====
|
|
213
|
+
|
|
214
|
+
history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
|
|
215
|
+
if (method && path) {
|
|
216
|
+
return this.requestHistory.filter(
|
|
217
|
+
(r) => r.method === method && r.path === path,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return [...this.requestHistory];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
called(method?: Schmock.HttpMethod, path?: string): boolean {
|
|
224
|
+
if (method && path) {
|
|
225
|
+
return this.requestHistory.some(
|
|
226
|
+
(r) => r.method === method && r.path === path,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return this.requestHistory.length > 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
callCount(method?: Schmock.HttpMethod, path?: string): number {
|
|
233
|
+
if (method && path) {
|
|
234
|
+
return this.requestHistory.filter(
|
|
235
|
+
(r) => r.method === method && r.path === path,
|
|
236
|
+
).length;
|
|
237
|
+
}
|
|
238
|
+
return this.requestHistory.length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
lastRequest(
|
|
242
|
+
method?: Schmock.HttpMethod,
|
|
243
|
+
path?: string,
|
|
244
|
+
): Schmock.RequestRecord | undefined {
|
|
245
|
+
if (method && path) {
|
|
246
|
+
const filtered = this.requestHistory.filter(
|
|
247
|
+
(r) => r.method === method && r.path === path,
|
|
248
|
+
);
|
|
249
|
+
return filtered[filtered.length - 1];
|
|
250
|
+
}
|
|
251
|
+
return this.requestHistory[this.requestHistory.length - 1];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ===== Reset / Lifecycle =====
|
|
255
|
+
|
|
256
|
+
reset(): void {
|
|
257
|
+
this.routes = [];
|
|
258
|
+
this.staticRoutes.clear();
|
|
259
|
+
this.plugins = [];
|
|
260
|
+
this.requestHistory = [];
|
|
261
|
+
if (this.globalConfig.state) {
|
|
262
|
+
for (const key of Object.keys(this.globalConfig.state)) {
|
|
263
|
+
delete this.globalConfig.state[key];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
this.logger.log("lifecycle", "Mock fully reset");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
resetHistory(): void {
|
|
270
|
+
this.requestHistory = [];
|
|
271
|
+
this.logger.log("lifecycle", "Request history cleared");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
resetState(): void {
|
|
275
|
+
if (this.globalConfig.state) {
|
|
276
|
+
for (const key of Object.keys(this.globalConfig.state)) {
|
|
277
|
+
delete this.globalConfig.state[key];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
this.logger.log("lifecycle", "State cleared");
|
|
281
|
+
}
|
|
282
|
+
|
|
177
283
|
async handle(
|
|
178
|
-
method: HttpMethod,
|
|
284
|
+
method: Schmock.HttpMethod,
|
|
179
285
|
path: string,
|
|
180
|
-
options?: RequestOptions,
|
|
181
|
-
): Promise<Response> {
|
|
182
|
-
const requestId =
|
|
286
|
+
options?: Schmock.RequestOptions,
|
|
287
|
+
): Promise<Schmock.Response> {
|
|
288
|
+
const requestId = crypto.randomUUID();
|
|
183
289
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
184
290
|
headers: options?.headers,
|
|
185
291
|
query: options?.query,
|
|
@@ -190,46 +296,40 @@ export class CallableMockInstance {
|
|
|
190
296
|
try {
|
|
191
297
|
// Apply namespace if configured
|
|
192
298
|
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
|
-
}
|
|
299
|
+
if (this.globalConfig.namespace && this.globalConfig.namespace !== "/") {
|
|
300
|
+
const namespace = this.globalConfig.namespace.startsWith("/")
|
|
301
|
+
? this.globalConfig.namespace
|
|
302
|
+
: `/${this.globalConfig.namespace}`;
|
|
303
|
+
|
|
304
|
+
const pathToCheck = path.startsWith("/") ? path : `/${path}`;
|
|
305
|
+
|
|
306
|
+
// Check if path starts with namespace
|
|
307
|
+
// handle both "/api/users" (starts with /api) and "/api" (exact match)
|
|
308
|
+
// but NOT "/apiv2" (prefix match but wrong segment)
|
|
309
|
+
const isMatch =
|
|
310
|
+
pathToCheck === namespace ||
|
|
311
|
+
pathToCheck.startsWith(
|
|
312
|
+
namespace.endsWith("/") ? namespace : `${namespace}/`,
|
|
313
|
+
);
|
|
226
314
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
315
|
+
if (!isMatch) {
|
|
316
|
+
this.logger.log(
|
|
317
|
+
"route",
|
|
318
|
+
`[${requestId}] Path doesn't match namespace ${namespace}`,
|
|
319
|
+
);
|
|
320
|
+
const error = new RouteNotFoundError(method, path);
|
|
321
|
+
const response = {
|
|
322
|
+
status: 404,
|
|
323
|
+
body: { error: error.message, code: error.code },
|
|
324
|
+
headers: {},
|
|
325
|
+
};
|
|
326
|
+
this.logger.timeEnd(`request-${requestId}`);
|
|
327
|
+
return response;
|
|
232
328
|
}
|
|
329
|
+
|
|
330
|
+
// Remove namespace prefix, ensuring we always start with /
|
|
331
|
+
const stripped = pathToCheck.slice(namespace.length);
|
|
332
|
+
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
233
333
|
}
|
|
234
334
|
|
|
235
335
|
// Find matching route
|
|
@@ -259,7 +359,7 @@ export class CallableMockInstance {
|
|
|
259
359
|
const params = this.extractParams(matchedRoute, requestPath);
|
|
260
360
|
|
|
261
361
|
// Generate initial response from route handler
|
|
262
|
-
const context: RequestContext = {
|
|
362
|
+
const context: Schmock.RequestContext = {
|
|
263
363
|
method,
|
|
264
364
|
path: requestPath,
|
|
265
365
|
params,
|
|
@@ -269,15 +369,15 @@ export class CallableMockInstance {
|
|
|
269
369
|
state: this.globalConfig.state || {},
|
|
270
370
|
};
|
|
271
371
|
|
|
272
|
-
let result:
|
|
273
|
-
if (
|
|
274
|
-
result = await
|
|
372
|
+
let result: unknown;
|
|
373
|
+
if (isGeneratorFunction(matchedRoute.generator)) {
|
|
374
|
+
result = await matchedRoute.generator(context);
|
|
275
375
|
} else {
|
|
276
376
|
result = matchedRoute.generator;
|
|
277
377
|
}
|
|
278
378
|
|
|
279
379
|
// Build plugin context
|
|
280
|
-
let pluginContext: PluginContext = {
|
|
380
|
+
let pluginContext: Schmock.PluginContext = {
|
|
281
381
|
path: requestPath,
|
|
282
382
|
route: matchedRoute.config,
|
|
283
383
|
method,
|
|
@@ -302,7 +402,7 @@ export class CallableMockInstance {
|
|
|
302
402
|
} catch (error) {
|
|
303
403
|
this.logger.log(
|
|
304
404
|
"error",
|
|
305
|
-
`[${requestId}] Plugin pipeline error: ${(error
|
|
405
|
+
`[${requestId}] Plugin pipeline error: ${errorMessage(error)}`,
|
|
306
406
|
);
|
|
307
407
|
throw error;
|
|
308
408
|
}
|
|
@@ -313,6 +413,18 @@ export class CallableMockInstance {
|
|
|
313
413
|
// Apply global delay if configured
|
|
314
414
|
await this.applyDelay();
|
|
315
415
|
|
|
416
|
+
// Record request in history
|
|
417
|
+
this.requestHistory.push({
|
|
418
|
+
method,
|
|
419
|
+
path: requestPath,
|
|
420
|
+
params,
|
|
421
|
+
query: options?.query || {},
|
|
422
|
+
headers: options?.headers || {},
|
|
423
|
+
body: options?.body,
|
|
424
|
+
timestamp: Date.now(),
|
|
425
|
+
response: { status: response.status, body: response.body },
|
|
426
|
+
});
|
|
427
|
+
|
|
316
428
|
// Log successful response
|
|
317
429
|
this.logger.log(
|
|
318
430
|
"response",
|
|
@@ -329,7 +441,7 @@ export class CallableMockInstance {
|
|
|
329
441
|
} catch (error) {
|
|
330
442
|
this.logger.log(
|
|
331
443
|
"error",
|
|
332
|
-
`[${requestId}] Error processing request: ${(error
|
|
444
|
+
`[${requestId}] Error processing request: ${errorMessage(error)}`,
|
|
333
445
|
error,
|
|
334
446
|
);
|
|
335
447
|
|
|
@@ -337,11 +449,8 @@ export class CallableMockInstance {
|
|
|
337
449
|
const errorResponse = {
|
|
338
450
|
status: 500,
|
|
339
451
|
body: {
|
|
340
|
-
error: (error
|
|
341
|
-
code:
|
|
342
|
-
error instanceof SchmockError
|
|
343
|
-
? (error as SchmockError).code
|
|
344
|
-
: "INTERNAL_ERROR",
|
|
452
|
+
error: errorMessage(error),
|
|
453
|
+
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
345
454
|
},
|
|
346
455
|
headers: {},
|
|
347
456
|
};
|
|
@@ -382,20 +491,18 @@ export class CallableMockInstance {
|
|
|
382
491
|
* @returns Normalized Response object with status, body, and headers
|
|
383
492
|
* @private
|
|
384
493
|
*/
|
|
385
|
-
private parseResponse(
|
|
494
|
+
private parseResponse(
|
|
495
|
+
result: unknown,
|
|
496
|
+
routeConfig: Schmock.RouteConfig,
|
|
497
|
+
): Schmock.Response {
|
|
386
498
|
let status = 200;
|
|
387
|
-
let body = result;
|
|
499
|
+
let body: unknown = result;
|
|
388
500
|
let headers: Record<string, string> = {};
|
|
389
501
|
|
|
390
502
|
let tupleFormat = false;
|
|
391
503
|
|
|
392
504
|
// 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
|
-
) {
|
|
505
|
+
if (isResponseObject(result)) {
|
|
399
506
|
return {
|
|
400
507
|
status: result.status,
|
|
401
508
|
body: result.body,
|
|
@@ -404,7 +511,7 @@ export class CallableMockInstance {
|
|
|
404
511
|
}
|
|
405
512
|
|
|
406
513
|
// Handle tuple response format [status, body, headers?]
|
|
407
|
-
if (
|
|
514
|
+
if (isStatusTuple(result)) {
|
|
408
515
|
[status, body, headers = {}] = result;
|
|
409
516
|
tupleFormat = true;
|
|
410
517
|
}
|
|
@@ -452,13 +559,13 @@ export class CallableMockInstance {
|
|
|
452
559
|
* @private
|
|
453
560
|
*/
|
|
454
561
|
private async runPluginPipeline(
|
|
455
|
-
context: PluginContext,
|
|
456
|
-
initialResponse?:
|
|
457
|
-
_routeConfig?: RouteConfig,
|
|
562
|
+
context: Schmock.PluginContext,
|
|
563
|
+
initialResponse?: unknown,
|
|
564
|
+
_routeConfig?: Schmock.RouteConfig,
|
|
458
565
|
_requestId?: string,
|
|
459
|
-
): Promise<{ context: PluginContext; response?:
|
|
566
|
+
): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
|
|
460
567
|
let currentContext = context;
|
|
461
|
-
let response:
|
|
568
|
+
let response: unknown = initialResponse;
|
|
462
569
|
|
|
463
570
|
this.logger.log(
|
|
464
571
|
"pipeline",
|
|
@@ -497,14 +604,16 @@ export class CallableMockInstance {
|
|
|
497
604
|
} catch (error) {
|
|
498
605
|
this.logger.log(
|
|
499
606
|
"pipeline",
|
|
500
|
-
`Plugin ${plugin.name} failed: ${(error
|
|
607
|
+
`Plugin ${plugin.name} failed: ${errorMessage(error)}`,
|
|
501
608
|
);
|
|
502
609
|
|
|
503
610
|
// Try error handling if plugin has onError hook
|
|
504
611
|
if (plugin.onError) {
|
|
505
612
|
try {
|
|
613
|
+
const pluginError =
|
|
614
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
506
615
|
const errorResult = await plugin.onError(
|
|
507
|
-
|
|
616
|
+
pluginError,
|
|
508
617
|
currentContext,
|
|
509
618
|
);
|
|
510
619
|
if (errorResult) {
|
|
@@ -512,26 +621,38 @@ export class CallableMockInstance {
|
|
|
512
621
|
"pipeline",
|
|
513
622
|
`Plugin ${plugin.name} handled error`,
|
|
514
623
|
);
|
|
515
|
-
|
|
624
|
+
|
|
625
|
+
// Error return → transform the thrown error
|
|
626
|
+
if (errorResult instanceof Error) {
|
|
627
|
+
throw new PluginError(plugin.name, errorResult);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ResponseResult return → recover, stop pipeline
|
|
516
631
|
if (
|
|
517
632
|
typeof errorResult === "object" &&
|
|
518
633
|
errorResult !== null &&
|
|
519
634
|
"status" in errorResult
|
|
520
635
|
) {
|
|
521
|
-
// Return the error response as the current response, stop pipeline
|
|
522
636
|
response = errorResult;
|
|
523
637
|
break;
|
|
524
638
|
}
|
|
525
639
|
}
|
|
640
|
+
// void/falsy return → propagate original error below
|
|
526
641
|
} catch (hookError) {
|
|
642
|
+
// If the hook itself threw (including our PluginError above), re-throw it
|
|
643
|
+
if (hookError instanceof PluginError) {
|
|
644
|
+
throw hookError;
|
|
645
|
+
}
|
|
527
646
|
this.logger.log(
|
|
528
647
|
"pipeline",
|
|
529
|
-
`Plugin ${plugin.name} error handler failed: ${(hookError
|
|
648
|
+
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
530
649
|
);
|
|
531
650
|
}
|
|
532
651
|
}
|
|
533
652
|
|
|
534
|
-
|
|
653
|
+
const cause =
|
|
654
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
655
|
+
throw new PluginError(plugin.name, cause);
|
|
535
656
|
}
|
|
536
657
|
}
|
|
537
658
|
|
|
@@ -548,21 +669,18 @@ export class CallableMockInstance {
|
|
|
548
669
|
* @private
|
|
549
670
|
*/
|
|
550
671
|
private findRoute(
|
|
551
|
-
method: HttpMethod,
|
|
672
|
+
method: Schmock.HttpMethod,
|
|
552
673
|
path: string,
|
|
553
674
|
): CompiledCallableRoute | undefined {
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
) {
|
|
561
|
-
return route;
|
|
562
|
-
}
|
|
675
|
+
// O(1) lookup for static routes
|
|
676
|
+
const normalizedPath =
|
|
677
|
+
path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
678
|
+
const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
|
|
679
|
+
if (staticMatch) {
|
|
680
|
+
return staticMatch;
|
|
563
681
|
}
|
|
564
682
|
|
|
565
|
-
//
|
|
683
|
+
// Fall through to parameterized route scan
|
|
566
684
|
for (const route of this.routes) {
|
|
567
685
|
if (
|
|
568
686
|
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 {
|
|
@@ -23,3 +23,19 @@ export function toHttpMethod(method: string): HttpMethod {
|
|
|
23
23
|
}
|
|
24
24
|
return upper;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a value is a status tuple: [status, body] or [status, body, headers]
|
|
29
|
+
* Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
|
|
30
|
+
*/
|
|
31
|
+
export function isStatusTuple(
|
|
32
|
+
value: unknown,
|
|
33
|
+
): value is [number, unknown] | [number, unknown, Record<string, string>] {
|
|
34
|
+
return (
|
|
35
|
+
Array.isArray(value) &&
|
|
36
|
+
(value.length === 2 || value.length === 3) &&
|
|
37
|
+
typeof value[0] === "number" &&
|
|
38
|
+
value[0] >= 100 &&
|
|
39
|
+
value[0] <= 599
|
|
40
|
+
);
|
|
41
|
+
}
|
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,34 +23,48 @@ 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);
|
|
58
|
+
instance.setCallableRef(callableInstance);
|
|
54
59
|
|
|
55
|
-
return callableInstance
|
|
60
|
+
return callableInstance;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
// Re-export constants and utilities
|
|
59
64
|
export {
|
|
60
65
|
HTTP_METHODS,
|
|
61
66
|
isHttpMethod,
|
|
67
|
+
isStatusTuple,
|
|
62
68
|
ROUTE_NOT_FOUND_CODE,
|
|
63
69
|
toHttpMethod,
|
|
64
70
|
} from "./constants.js";
|
|
@@ -74,7 +80,6 @@ export {
|
|
|
74
80
|
SchemaValidationError,
|
|
75
81
|
SchmockError,
|
|
76
82
|
} from "./errors.js";
|
|
77
|
-
|
|
78
83
|
// Re-export types
|
|
79
84
|
export type {
|
|
80
85
|
CallableMockInstance,
|
|
@@ -87,6 +92,7 @@ export type {
|
|
|
87
92
|
PluginResult,
|
|
88
93
|
RequestContext,
|
|
89
94
|
RequestOptions,
|
|
95
|
+
RequestRecord,
|
|
90
96
|
Response,
|
|
91
97
|
ResponseBody,
|
|
92
98
|
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 () => {
|