@schmock/core 1.9.1 → 1.9.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAYnB,OAAO,CAAC,YAAY;IAXhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;IAEnD,OAAO,CAAC,SAAS,CAAoC;gBAEjC,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IAqFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAS5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAYpC,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAeb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAuCrE,KAAK,IAAI,IAAI;IAUP,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IAqN5B;;;;OAIG;YACW,UAAU;CAezB"}
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAYnB,OAAO,CAAC,YAAY;IAXhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;IAEnD,OAAO,CAAC,SAAS,CAAoC;gBAEjC,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IAiFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAS5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAYpC,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAeb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAqDrE,KAAK,IAAI,IAAI;IAUP,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IAoO5B;;;;OAIG;YACW,UAAU;CAezB"}
package/dist/builder.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { toHttpMethod } from "./constants.js";
2
+ import { normalizePath, toHttpMethod } from "./constants.js";
3
3
  import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
4
4
  import { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
5
5
  import { parseRouteKey } from "./parser.js";
@@ -119,10 +119,7 @@ export class CallableMockInstance {
119
119
  // Store static routes (no params) in Map for O(1) lookup
120
120
  // Only store the first registration — "first registration wins" semantics
121
121
  if (parsed.params.length === 0) {
122
- const normalizedPath = parsed.path.endsWith("/") && parsed.path !== "/"
123
- ? parsed.path.slice(0, -1)
124
- : parsed.path;
125
- const key = `${parsed.method} ${normalizedPath}`;
122
+ const key = `${parsed.method} ${normalizePath(parsed.path)}`;
126
123
  if (!this.staticRoutes.has(key)) {
127
124
  this.staticRoutes.set(key, compiledRoute);
128
125
  }
@@ -152,26 +149,26 @@ export class CallableMockInstance {
152
149
  }
153
150
  // ===== Request Spy / History API =====
154
151
  history(method, path) {
155
- if (method && path) {
156
- return this.requestHistory.filter((r) => r.method === method && r.path === path);
152
+ if (method || path) {
153
+ return this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
157
154
  }
158
155
  return [...this.requestHistory];
159
156
  }
160
157
  called(method, path) {
161
- if (method && path) {
162
- return this.requestHistory.some((r) => r.method === method && r.path === path);
158
+ if (method || path) {
159
+ return this.requestHistory.some((r) => (!method || r.method === method) && (!path || r.path === path));
163
160
  }
164
161
  return this.requestHistory.length > 0;
165
162
  }
166
163
  callCount(method, path) {
167
- if (method && path) {
168
- return this.requestHistory.filter((r) => r.method === method && r.path === path).length;
164
+ if (method || path) {
165
+ return this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path)).length;
169
166
  }
170
167
  return this.requestHistory.length;
171
168
  }
172
169
  lastRequest(method, path) {
173
- if (method && path) {
174
- const filtered = this.requestHistory.filter((r) => r.method === method && r.path === path);
170
+ if (method || path) {
171
+ const filtered = this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
175
172
  return filtered[filtered.length - 1];
176
173
  }
177
174
  return this.requestHistory[this.requestHistory.length - 1];
@@ -185,7 +182,7 @@ export class CallableMockInstance {
185
182
  }));
186
183
  }
187
184
  getState() {
188
- return this.globalConfig.state || {};
185
+ return { ...(this.globalConfig.state || {}) };
189
186
  }
190
187
  // ===== Lifecycle Events =====
191
188
  on(event, listener) {
@@ -242,14 +239,29 @@ export class CallableMockInstance {
242
239
  throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
243
240
  }
244
241
  const httpServer = createServer((req, res) => {
245
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
246
- const method = toHttpMethod(req.method ?? "GET");
247
- const path = url.pathname;
248
- const headers = parseNodeHeaders(req);
249
- const query = parseNodeQuery(url);
250
- void collectBody(req, headers).then((body) => this.handle(method, path, { headers, body, query }).then((schmockResponse) => {
242
+ const handleRequest = async () => {
243
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
244
+ const method = toHttpMethod(req.method ?? "GET");
245
+ const path = url.pathname;
246
+ const headers = parseNodeHeaders(req);
247
+ const query = parseNodeQuery(url);
248
+ const body = await collectBody(req, headers);
249
+ const schmockResponse = await this.handle(method, path, {
250
+ headers,
251
+ body,
252
+ query,
253
+ });
251
254
  writeSchmockResponse(res, schmockResponse);
252
- }));
255
+ };
256
+ handleRequest().catch((error) => {
257
+ if (!res.headersSent) {
258
+ res.writeHead(500, { "content-type": "application/json" });
259
+ }
260
+ res.end(JSON.stringify({
261
+ error: error instanceof Error ? error.message : "Internal Server Error",
262
+ code: "SERVER_ERROR",
263
+ }));
264
+ });
253
265
  });
254
266
  this.server = httpServer;
255
267
  return new Promise((resolve, reject) => {
@@ -273,18 +285,20 @@ export class CallableMockInstance {
273
285
  this.logger.log("server", "Server stopped");
274
286
  }
275
287
  async handle(method, path, options) {
276
- const requestId = crypto.randomUUID();
277
288
  const handleStart = performance.now();
289
+ const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
290
+ const reqQuery = options?.query || {};
291
+ const reqHeaders = options?.headers || {};
278
292
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
279
- headers: options?.headers,
280
- query: options?.query,
293
+ headers: reqHeaders,
294
+ query: reqQuery,
281
295
  bodyType: options?.body ? typeof options.body : "none",
282
296
  });
283
297
  this.logger.time(`request-${requestId}`);
284
298
  this.emit("request:start", {
285
299
  method,
286
300
  path,
287
- headers: options?.headers || {},
301
+ headers: reqHeaders,
288
302
  });
289
303
  try {
290
304
  // Apply namespace if configured
@@ -307,6 +321,12 @@ export class CallableMockInstance {
307
321
  body: { error: error.message, code: error.code },
308
322
  headers: {},
309
323
  };
324
+ this.emit("request:end", {
325
+ method,
326
+ path,
327
+ status: 404,
328
+ duration: performance.now() - handleStart,
329
+ });
310
330
  this.logger.timeEnd(`request-${requestId}`);
311
331
  return response;
312
332
  }
@@ -348,8 +368,8 @@ export class CallableMockInstance {
348
368
  method,
349
369
  path: requestPath,
350
370
  params,
351
- query: options?.query || {},
352
- headers: options?.headers || {},
371
+ query: reqQuery,
372
+ headers: reqHeaders,
353
373
  body: options?.body,
354
374
  state: this.globalConfig.state || {},
355
375
  };
@@ -366,8 +386,8 @@ export class CallableMockInstance {
366
386
  route: matchedRoute.config,
367
387
  method,
368
388
  params,
369
- query: options?.query || {},
370
- headers: options?.headers || {},
389
+ query: reqQuery,
390
+ headers: reqHeaders,
371
391
  body: options?.body,
372
392
  state: new Map(),
373
393
  routeState: this.globalConfig.state || {},
@@ -391,8 +411,8 @@ export class CallableMockInstance {
391
411
  method,
392
412
  path: requestPath,
393
413
  params,
394
- query: options?.query || {},
395
- headers: options?.headers || {},
414
+ query: reqQuery,
415
+ headers: reqHeaders,
396
416
  body: options?.body,
397
417
  timestamp: Date.now(),
398
418
  response: { status: response.status, body: response.body },
@@ -425,6 +445,12 @@ export class CallableMockInstance {
425
445
  };
426
446
  // Apply delay even for error responses
427
447
  await this.applyDelay();
448
+ this.emit("request:end", {
449
+ method,
450
+ path,
451
+ status: 500,
452
+ duration: performance.now() - handleStart,
453
+ });
428
454
  this.logger.log("error", `[${requestId}] Returning error response 500`);
429
455
  this.logger.timeEnd(`request-${requestId}`);
430
456
  return errorResponse;
@@ -3,6 +3,8 @@ export declare const ROUTE_NOT_FOUND_CODE: "ROUTE_NOT_FOUND";
3
3
  export declare const HTTP_METHODS: readonly HttpMethod[];
4
4
  export declare function isHttpMethod(method: string): method is HttpMethod;
5
5
  export declare function toHttpMethod(method: string): HttpMethod;
6
+ export declare function normalizePath(path: string): string;
7
+ export declare function toRouteKey(method: HttpMethod, path: string): Schmock.RouteKey;
6
8
  /**
7
9
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
8
10
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAG,iBAA0B,CAAC;AAE/D,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,IAAI,UAAU,CAEjE;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMvD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAQxE"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAG,iBAA0B,CAAC;AAE/D,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,IAAI,UAAU,CAEjE;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMvD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAG7E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAQxE"}
package/dist/constants.js CHANGED
@@ -18,6 +18,13 @@ export function toHttpMethod(method) {
18
18
  }
19
19
  return upper;
20
20
  }
21
+ export function normalizePath(path) {
22
+ return path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
23
+ }
24
+ export function toRouteKey(method, path) {
25
+ const key = `${method} ${path}`;
26
+ return key;
27
+ }
21
28
  /**
22
29
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
23
30
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
@@ -12,8 +12,11 @@ export declare function parseNodeQuery(url: URL): Record<string, string>;
12
12
  * Collect and parse the request body from a Node.js IncomingMessage.
13
13
  * Returns parsed JSON if content-type includes "json", otherwise the raw string.
14
14
  * Returns undefined for empty bodies.
15
+ * @param req - Node.js IncomingMessage
16
+ * @param headers - Parsed request headers
17
+ * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
15
18
  */
16
- export declare function collectBody(req: IncomingMessage, headers: Record<string, string>): Promise<unknown>;
19
+ export declare function collectBody(req: IncomingMessage, headers: Record<string, string>, maxBodySize?: number): Promise<unknown>;
17
20
  /**
18
21
  * Write a Schmock Response to a Node.js ServerResponse.
19
22
  * Serializes non-string bodies as JSON and sets content-type when missing.
@@ -1 +1 @@
1
- {"version":3,"file":"http-helpers.d.ts","sourceRoot":"","sources":["../src/http-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ7E;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAM/D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC,OAAO,CAAC,CAsBlB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACpC,IAAI,CAuBN"}
1
+ {"version":3,"file":"http-helpers.d.ts","sourceRoot":"","sources":["../src/http-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ7E;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAM/D;AAKD;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,WAAW,SAAwB,GAClC,OAAO,CAAC,OAAO,CAAC,CAqClB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACpC,IAAI,CAuBN"}
@@ -21,15 +21,30 @@ export function parseNodeQuery(url) {
21
21
  });
22
22
  return query;
23
23
  }
24
+ /** Default body size limit: 10 MB */
25
+ const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
24
26
  /**
25
27
  * Collect and parse the request body from a Node.js IncomingMessage.
26
28
  * Returns parsed JSON if content-type includes "json", otherwise the raw string.
27
29
  * Returns undefined for empty bodies.
30
+ * @param req - Node.js IncomingMessage
31
+ * @param headers - Parsed request headers
32
+ * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
28
33
  */
29
- export function collectBody(req, headers) {
30
- return new Promise((resolve) => {
34
+ export function collectBody(req, headers, maxBodySize = DEFAULT_MAX_BODY_SIZE) {
35
+ return new Promise((resolve, reject) => {
31
36
  const chunks = [];
32
- req.on("data", (chunk) => chunks.push(chunk));
37
+ let totalSize = 0;
38
+ req.on("error", reject);
39
+ req.on("data", (chunk) => {
40
+ totalSize += chunk.length;
41
+ if (totalSize > maxBodySize) {
42
+ req.destroy();
43
+ reject(Object.assign(new Error("Request body too large"), { status: 413 }));
44
+ return;
45
+ }
46
+ chunks.push(chunk);
47
+ });
33
48
  req.on("end", () => {
34
49
  const raw = Buffer.concat(chunks).toString();
35
50
  if (!raw) {
package/dist/index.d.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * @returns A callable mock instance
23
23
  */
24
24
  export declare function schmock(config?: Schmock.GlobalConfig): Schmock.CallableMockInstance;
25
- export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
25
+ export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, toRouteKey, } from "./constants.js";
26
26
  export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
27
27
  export { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
28
28
  export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, RequestRecord, Response, ResponseBody, ResponseResult, RouteConfig, RouteInfo, RouteKey, ServerInfo, StaticData, } from "./types.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CAmD9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,GACb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,GACX,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CAmD9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,EACZ,UAAU,GACX,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,GACX,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -59,7 +59,7 @@ export function schmock(config) {
59
59
  return callableInstance;
60
60
  }
61
61
  // Re-export constants and utilities
62
- export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
62
+ export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, toRouteKey, } from "./constants.js";
63
63
  // Re-export errors
64
64
  export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
65
65
  // Re-export HTTP server helpers
@@ -1 +1 @@
1
- {"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../src/route-matcher.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC;IAC7B,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC;CAC7B;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,OAAO,CAAC,SAAS,GACrB,GAAG,IAAI,OAAO,CAAC,iBAAiB,CAElC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,qBAAqB,CAAC,EAChD,MAAM,EAAE,qBAAqB,EAAE,GAC9B,qBAAqB,GAAG,SAAS,CAqBnC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,qBAAqB,EAC5B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUxB"}
1
+ {"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../src/route-matcher.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC;IAC7B,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC;CAC7B;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,OAAO,CAAC,SAAS,GACrB,GAAG,IAAI,OAAO,CAAC,iBAAiB,CAElC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,qBAAqB,CAAC,EAChD,MAAM,EAAE,qBAAqB,EAAE,GAC9B,qBAAqB,GAAG,SAAS,CAmBnC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,qBAAqB,EAC5B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUxB"}
@@ -1,3 +1,4 @@
1
+ import { normalizePath } from "./constants.js";
1
2
  export function isGeneratorFunction(gen) {
2
3
  return typeof gen === "function";
3
4
  }
@@ -8,8 +9,7 @@ export function isGeneratorFunction(gen) {
8
9
  */
9
10
  export function findRoute(method, path, staticRoutes, routes) {
10
11
  // O(1) lookup for static routes
11
- const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
12
- const staticMatch = staticRoutes.get(`${method} ${normalizedPath}`);
12
+ const staticMatch = staticRoutes.get(`${method} ${normalizePath(path)}`);
13
13
  if (staticMatch) {
14
14
  return staticMatch;
15
15
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "1.9.1",
4
+ "version": "1.9.4",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -33,7 +33,8 @@
33
33
  "license": "MIT",
34
34
  "devDependencies": {
35
35
  "@amiceli/vitest-cucumber": "^6.2.0",
36
- "@types/node": "^25.2.3",
36
+ "@types/json-schema": "^7.0.15",
37
+ "@types/node": "^25.3.0",
37
38
  "@vitest/ui": "^4.0.18",
38
39
  "vitest": "^4.0.18"
39
40
  }
package/src/builder.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Server } from "node:http";
2
2
  import { createServer } from "node:http";
3
- import { toHttpMethod } from "./constants.js";
3
+ import { normalizePath, toHttpMethod } from "./constants.js";
4
4
  import {
5
5
  errorMessage,
6
6
  RouteDefinitionError,
@@ -153,11 +153,7 @@ export class CallableMockInstance {
153
153
  // Store static routes (no params) in Map for O(1) lookup
154
154
  // Only store the first registration — "first registration wins" semantics
155
155
  if (parsed.params.length === 0) {
156
- const normalizedPath =
157
- parsed.path.endsWith("/") && parsed.path !== "/"
158
- ? parsed.path.slice(0, -1)
159
- : parsed.path;
160
- const key = `${parsed.method} ${normalizedPath}`;
156
+ const key = `${parsed.method} ${normalizePath(parsed.path)}`;
161
157
  if (!this.staticRoutes.has(key)) {
162
158
  this.staticRoutes.set(key, compiledRoute);
163
159
  }
@@ -197,27 +193,27 @@ export class CallableMockInstance {
197
193
  // ===== Request Spy / History API =====
198
194
 
199
195
  history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
200
- if (method && path) {
196
+ if (method || path) {
201
197
  return this.requestHistory.filter(
202
- (r) => r.method === method && r.path === path,
198
+ (r) => (!method || r.method === method) && (!path || r.path === path),
203
199
  );
204
200
  }
205
201
  return [...this.requestHistory];
206
202
  }
207
203
 
208
204
  called(method?: Schmock.HttpMethod, path?: string): boolean {
209
- if (method && path) {
205
+ if (method || path) {
210
206
  return this.requestHistory.some(
211
- (r) => r.method === method && r.path === path,
207
+ (r) => (!method || r.method === method) && (!path || r.path === path),
212
208
  );
213
209
  }
214
210
  return this.requestHistory.length > 0;
215
211
  }
216
212
 
217
213
  callCount(method?: Schmock.HttpMethod, path?: string): number {
218
- if (method && path) {
214
+ if (method || path) {
219
215
  return this.requestHistory.filter(
220
- (r) => r.method === method && r.path === path,
216
+ (r) => (!method || r.method === method) && (!path || r.path === path),
221
217
  ).length;
222
218
  }
223
219
  return this.requestHistory.length;
@@ -227,9 +223,9 @@ export class CallableMockInstance {
227
223
  method?: Schmock.HttpMethod,
228
224
  path?: string,
229
225
  ): Schmock.RequestRecord | undefined {
230
- if (method && path) {
226
+ if (method || path) {
231
227
  const filtered = this.requestHistory.filter(
232
- (r) => r.method === method && r.path === path,
228
+ (r) => (!method || r.method === method) && (!path || r.path === path),
233
229
  );
234
230
  return filtered[filtered.length - 1];
235
231
  }
@@ -247,7 +243,7 @@ export class CallableMockInstance {
247
243
  }
248
244
 
249
245
  getState(): Record<string, unknown> {
250
- return this.globalConfig.state || {};
246
+ return { ...(this.globalConfig.state || {}) };
251
247
  }
252
248
 
253
249
  // ===== Lifecycle Events =====
@@ -324,19 +320,33 @@ export class CallableMockInstance {
324
320
  }
325
321
 
326
322
  const httpServer = createServer((req, res) => {
327
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
328
- const method = toHttpMethod(req.method ?? "GET");
329
- const path = url.pathname;
330
- const headers = parseNodeHeaders(req);
331
- const query = parseNodeQuery(url);
332
-
333
- void collectBody(req, headers).then((body) =>
334
- this.handle(method, path, { headers, body, query }).then(
335
- (schmockResponse) => {
336
- writeSchmockResponse(res, schmockResponse);
337
- },
338
- ),
339
- );
323
+ const handleRequest = async () => {
324
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
325
+ const method = toHttpMethod(req.method ?? "GET");
326
+ const path = url.pathname;
327
+ const headers = parseNodeHeaders(req);
328
+ const query = parseNodeQuery(url);
329
+ const body = await collectBody(req, headers);
330
+ const schmockResponse = await this.handle(method, path, {
331
+ headers,
332
+ body,
333
+ query,
334
+ });
335
+ writeSchmockResponse(res, schmockResponse);
336
+ };
337
+
338
+ handleRequest().catch((error) => {
339
+ if (!res.headersSent) {
340
+ res.writeHead(500, { "content-type": "application/json" });
341
+ }
342
+ res.end(
343
+ JSON.stringify({
344
+ error:
345
+ error instanceof Error ? error.message : "Internal Server Error",
346
+ code: "SERVER_ERROR",
347
+ }),
348
+ );
349
+ });
340
350
  });
341
351
 
342
352
  this.server = httpServer;
@@ -369,11 +379,13 @@ export class CallableMockInstance {
369
379
  path: string,
370
380
  options?: Schmock.RequestOptions,
371
381
  ): Promise<Schmock.Response> {
372
- const requestId = crypto.randomUUID();
373
382
  const handleStart = performance.now();
383
+ const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
384
+ const reqQuery = options?.query || {};
385
+ const reqHeaders = options?.headers || {};
374
386
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
375
- headers: options?.headers,
376
- query: options?.query,
387
+ headers: reqHeaders,
388
+ query: reqQuery,
377
389
  bodyType: options?.body ? typeof options.body : "none",
378
390
  });
379
391
  this.logger.time(`request-${requestId}`);
@@ -381,7 +393,7 @@ export class CallableMockInstance {
381
393
  this.emit("request:start", {
382
394
  method,
383
395
  path,
384
- headers: options?.headers || {},
396
+ headers: reqHeaders,
385
397
  });
386
398
 
387
399
  try {
@@ -414,6 +426,12 @@ export class CallableMockInstance {
414
426
  body: { error: error.message, code: error.code },
415
427
  headers: {},
416
428
  };
429
+ this.emit("request:end", {
430
+ method,
431
+ path,
432
+ status: 404,
433
+ duration: performance.now() - handleStart,
434
+ });
417
435
  this.logger.timeEnd(`request-${requestId}`);
418
436
  return response;
419
437
  }
@@ -473,8 +491,8 @@ export class CallableMockInstance {
473
491
  method,
474
492
  path: requestPath,
475
493
  params,
476
- query: options?.query || {},
477
- headers: options?.headers || {},
494
+ query: reqQuery,
495
+ headers: reqHeaders,
478
496
  body: options?.body,
479
497
  state: this.globalConfig.state || {},
480
498
  };
@@ -492,8 +510,8 @@ export class CallableMockInstance {
492
510
  route: matchedRoute.config,
493
511
  method,
494
512
  params,
495
- query: options?.query || {},
496
- headers: options?.headers || {},
513
+ query: reqQuery,
514
+ headers: reqHeaders,
497
515
  body: options?.body,
498
516
  state: new Map(),
499
517
  routeState: this.globalConfig.state || {},
@@ -528,8 +546,8 @@ export class CallableMockInstance {
528
546
  method,
529
547
  path: requestPath,
530
548
  params,
531
- query: options?.query || {},
532
- headers: options?.headers || {},
549
+ query: reqQuery,
550
+ headers: reqHeaders,
533
551
  body: options?.body,
534
552
  timestamp: Date.now(),
535
553
  response: { status: response.status, body: response.body },
@@ -575,6 +593,13 @@ export class CallableMockInstance {
575
593
  // Apply delay even for error responses
576
594
  await this.applyDelay();
577
595
 
596
+ this.emit("request:end", {
597
+ method,
598
+ path,
599
+ status: 500,
600
+ duration: performance.now() - handleStart,
601
+ });
602
+
578
603
  this.logger.log("error", `[${requestId}] Returning error response 500`);
579
604
  this.logger.timeEnd(`request-${requestId}`);
580
605
  return errorResponse;
package/src/constants.ts CHANGED
@@ -24,6 +24,15 @@ export function toHttpMethod(method: string): HttpMethod {
24
24
  return upper;
25
25
  }
26
26
 
27
+ export function normalizePath(path: string): string {
28
+ return path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
29
+ }
30
+
31
+ export function toRouteKey(method: HttpMethod, path: string): Schmock.RouteKey {
32
+ const key: `${HttpMethod} ${string}` = `${method} ${path}`;
33
+ return key;
34
+ }
35
+
27
36
  /**
28
37
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
29
38
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
@@ -25,18 +25,40 @@ export function parseNodeQuery(url: URL): Record<string, string> {
25
25
  return query;
26
26
  }
27
27
 
28
+ /** Default body size limit: 10 MB */
29
+ const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
30
+
28
31
  /**
29
32
  * Collect and parse the request body from a Node.js IncomingMessage.
30
33
  * Returns parsed JSON if content-type includes "json", otherwise the raw string.
31
34
  * Returns undefined for empty bodies.
35
+ * @param req - Node.js IncomingMessage
36
+ * @param headers - Parsed request headers
37
+ * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
32
38
  */
33
39
  export function collectBody(
34
40
  req: IncomingMessage,
35
41
  headers: Record<string, string>,
42
+ maxBodySize = DEFAULT_MAX_BODY_SIZE,
36
43
  ): Promise<unknown> {
37
- return new Promise((resolve) => {
44
+ return new Promise((resolve, reject) => {
38
45
  const chunks: Buffer[] = [];
39
- req.on("data", (chunk: Buffer) => chunks.push(chunk));
46
+ let totalSize = 0;
47
+
48
+ req.on("error", reject);
49
+
50
+ req.on("data", (chunk: Buffer) => {
51
+ totalSize += chunk.length;
52
+ if (totalSize > maxBodySize) {
53
+ req.destroy();
54
+ reject(
55
+ Object.assign(new Error("Request body too large"), { status: 413 }),
56
+ );
57
+ return;
58
+ }
59
+ chunks.push(chunk);
60
+ });
61
+
40
62
  req.on("end", () => {
41
63
  const raw = Buffer.concat(chunks).toString();
42
64
  if (!raw) {
package/src/index.ts CHANGED
@@ -85,6 +85,7 @@ export {
85
85
  isStatusTuple,
86
86
  ROUTE_NOT_FOUND_CODE,
87
87
  toHttpMethod,
88
+ toRouteKey,
88
89
  } from "./constants.js";
89
90
  // Re-export errors
90
91
  export {
@@ -1,3 +1,5 @@
1
+ import { normalizePath } from "./constants.js";
2
+
1
3
  /**
2
4
  * Compiled callable route with pattern matching
3
5
  */
@@ -28,9 +30,7 @@ export function findRoute(
28
30
  routes: CompiledCallableRoute[],
29
31
  ): CompiledCallableRoute | undefined {
30
32
  // O(1) lookup for static routes
31
- const normalizedPath =
32
- path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
33
- const staticMatch = staticRoutes.get(`${method} ${normalizedPath}`);
33
+ const staticMatch = staticRoutes.get(`${method} ${normalizePath(path)}`);
34
34
  if (staticMatch) {
35
35
  return staticMatch;
36
36
  }
@@ -365,9 +365,8 @@ describeFeature(feature, ({ Scenario }) => {
365
365
  responses = await Promise.all(promises);
366
366
  });
367
367
 
368
- Then("all responses should have different processedAt timestamps", () => {
368
+ Then("all responses should have valid processedAt timestamps", () => {
369
369
  const timestamps = responses.map(r => r.body.processedAt);
370
- // In fast test environments, timestamps might be the same, so just check they exist
371
370
  expect(timestamps).toHaveLength(3);
372
371
  timestamps.forEach((timestamp: number) => expect(timestamp).toBeGreaterThan(0));
373
372
  });
@@ -378,8 +377,7 @@ describeFeature(feature, ({ Scenario }) => {
378
377
  expect(responses[2].body.id).toBe("3");
379
378
  });
380
379
 
381
- And("the responses should complete in expected order", () => {
382
- // All responses should be successful regardless of timing
380
+ And("all responses should complete with status 200", () => {
383
381
  for (const response of responses) {
384
382
  expect(response.status).toBe(200);
385
383
  expect(response.body).toHaveProperty("id");
@@ -180,8 +180,7 @@ describeFeature(feature, ({ Scenario }) => {
180
180
  }
181
181
  });
182
182
 
183
- And("the mock should handle the load gracefully", () => {
184
- // If we got here without throwing, the mock handled the load
183
+ And("each response should confirm large payload processing", () => {
185
184
  expect(responses.every(r => r.body.size === 'large')).toBe(true);
186
185
  });
187
186
 
@@ -202,8 +201,7 @@ describeFeature(feature, ({ Scenario }) => {
202
201
  }
203
202
  });
204
203
 
205
- And("the memory usage should remain stable", () => {
206
- // Memory stability is tested by not crashing during multiple large requests
204
+ And("all accumulation requests should complete successfully", () => {
207
205
  expect(responses).toHaveLength(5);
208
206
  });
209
207
  });
@@ -262,13 +260,12 @@ describeFeature(feature, ({ Scenario }) => {
262
260
  expect(successCount + errorCount).toBe(responses.length);
263
261
  });
264
262
 
265
- And("the success rate should be approximately {int}%", (_, expectedRate: number) => {
263
+ And("the success rate should be between {int}% and {int}%", (_, low: number, high: number) => {
266
264
  const successCount = responses.filter(r => r.status === 200).length;
267
265
  const actualRate = (successCount / responses.length) * 100;
268
266
 
269
- // Allow for some variance due to randomness
270
- expect(actualRate).toBeGreaterThan(expectedRate - 10);
271
- expect(actualRate).toBeLessThan(expectedRate + 10);
267
+ expect(actualRate).toBeGreaterThanOrEqual(low);
268
+ expect(actualRate).toBeLessThanOrEqual(high);
272
269
  });
273
270
 
274
271
  And("error responses should have appropriate status codes", () => {
@@ -298,7 +295,7 @@ describeFeature(feature, ({ Scenario }) => {
298
295
  }
299
296
  });
300
297
 
301
- Then("each error should have a specific, helpful error message", () => {
298
+ Then("each error response should have a non-empty error message", () => {
302
299
  for (const response of responses) {
303
300
  expect(response.status).toBeGreaterThanOrEqual(400);
304
301
  expect(response.body.error).toBeDefined();
@@ -307,7 +304,7 @@ describeFeature(feature, ({ Scenario }) => {
307
304
  }
308
305
  });
309
306
 
310
- And("the error codes should correctly identify the validation issue", () => {
307
+ And("each error response should have the correct status code", () => {
311
308
  expect(responses[0].status).toBe(400); // No content-type
312
309
  expect(responses[1].status).toBe(400); // No body
313
310
  expect(responses[2].status).toBe(400); // Invalid body type
@@ -259,13 +259,14 @@ describeFeature(feature, ({ Scenario }) => {
259
259
  requestResponses = [await mock.handle(method as any, path)];
260
260
  });
261
261
 
262
- Then("the response should have a {string} array", (_, property: string) => {
262
+ Then("the response should have a {string} array with {int} items", (_, property: string, count: number) => {
263
263
  expect(requestResponses[0].body).toHaveProperty(property);
264
264
  expect(Array.isArray(requestResponses[0].body[property])).toBe(true);
265
+ expect(requestResponses[0].body[property]).toHaveLength(count);
265
266
  });
266
267
 
267
- And("the response should have a {string} field", (_, property: string) => {
268
- expect(requestResponses[0].body).toHaveProperty(property);
268
+ And("the response should have {string} equal to {int}", (_, property: string, value: number) => {
269
+ expect(requestResponses[0].body[property]).toBe(value);
269
270
  });
270
271
 
271
272
  And(
@@ -58,8 +58,8 @@ describeFeature(feature, ({ Scenario }) => {
58
58
  elapsed = performance.now() - start;
59
59
  });
60
60
 
61
- Then("the response took at least 10ms", () => {
62
- expect(elapsed).toBeGreaterThanOrEqual(8); // small tolerance
61
+ Then("the response took at least 8ms", () => {
62
+ expect(elapsed).toBeGreaterThanOrEqual(8);
63
63
  });
64
64
  });
65
65
 
@@ -533,18 +533,18 @@ describeFeature(feature, ({ Scenario }) => {
533
533
  responses = await Promise.all(promises);
534
534
  });
535
535
 
536
- Then("the cache should maintain its size limit", () => {
536
+ Then("no response should report a cache size above 5", () => {
537
537
  responses.forEach(response => {
538
538
  expect((response.body as CacheResponseBody).cacheSize).toBeLessThanOrEqual(5);
539
539
  });
540
540
  });
541
541
 
542
- And("old items should be evicted properly", () => {
542
+ And("the final cache size should be 5", () => {
543
543
  const finalCacheSize = Math.max(...responses.map(r => (r.body as CacheResponseBody).cacheSize));
544
544
  expect(finalCacheSize).toBe(5);
545
545
  });
546
546
 
547
- And("cache size should remain consistent", () => {
547
+ And("each response should confirm the item was cached", () => {
548
548
  responses.forEach(response => {
549
549
  const body = response.body as CacheResponseBody;
550
550
  expect(body.cached).toBe(true);
@@ -617,7 +617,7 @@ describeFeature(feature, ({ Scenario }) => {
617
617
  expect(activityUpdates).toHaveLength(3);
618
618
  });
619
619
 
620
- And("no cross-contamination should occur between user data", () => {
620
+ And("user 1 should have 4 updates and user 2 should have 2", () => {
621
621
  const user1Updates = responses.filter(r => (r.body as ProfileResponseBody).userId === "1");
622
622
  const user2Updates = responses.filter(r => (r.body as ProfileResponseBody).userId === "2");
623
623
 
@@ -625,13 +625,7 @@ describeFeature(feature, ({ Scenario }) => {
625
625
  expect(user2Updates).toHaveLength(2); // profile, activity
626
626
  });
627
627
 
628
- And("nested state structure should remain intact", () => {
629
- responses.forEach(response => {
630
- const body = response.body as ProfileResponseBody;
631
- expect(body.userId).toBeDefined();
632
- expect(["1", "2"]).toContain(body.userId);
633
- });
634
-
628
+ And("user 1 activity count should be 2", () => {
635
629
  const activityUpdates = responses.filter(r => (r.body as ProfileResponseBody).activityCount);
636
630
  const user1Activities = activityUpdates.filter(r => (r.body as ProfileResponseBody).userId === "1");
637
631
  expect(user1Activities.length).toBe(2);
@@ -733,7 +727,7 @@ describeFeature(feature, ({ Scenario }) => {
733
727
  }
734
728
  });
735
729
 
736
- And("transaction history should be consistent", () => {
730
+ And("the transaction count should match the number of successful transactions", () => {
737
731
  const successfulTransactions = responses.filter(r => (r.body as TransactionResponseBody).success === true);
738
732
  if (successfulTransactions.length > 0) {
739
733
  const transactionCounts = successfulTransactions.map(r => (r.body as TransactionResponseBody).transactionCount);