@schmock/core 2.0.1 → 2.0.3

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":"AAwDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAanB,OAAO,CAAC,YAAY;IAZhC,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;IACnD,OAAO,CAAC,eAAe,CAAwC;IAE/D,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;IAiBb,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;IAab,SAAS,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,gBAAgB,GAAG,OAAO,CAAC,eAAe;IAgBhE,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"}
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAwDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAanB,OAAO,CAAC,YAAY;IAZhC,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;IACnD,OAAO,CAAC,eAAe,CAAwC;IAE/D,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;IA+EP,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;IAiBb,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;IAab,SAAS,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,gBAAgB,GAAG,OAAO,CAAC,eAAe;IAgBhE,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;IA+O5B;;;;OAIG;YACW,UAAU;CAezB"}
package/dist/builder.js CHANGED
@@ -103,10 +103,11 @@ export class CallableMockInstance {
103
103
  }
104
104
  // Parse the route key to create pattern and extract parameters
105
105
  const parsed = parseRouteKey(route);
106
- // Check for duplicate routes
106
+ // Check for duplicate routes — first registration wins, subsequent ones are dropped
107
107
  const existing = this.routes.find((r) => r.method === parsed.method && r.path === parsed.path);
108
108
  if (existing) {
109
109
  this.logger.log("warning", `Duplicate route: ${route} — first registration wins`);
110
+ return this;
110
111
  }
111
112
  // Compile the route
112
113
  const compiledRoute = {
@@ -119,12 +120,9 @@ export class CallableMockInstance {
119
120
  };
120
121
  this.routes.push(compiledRoute);
121
122
  // Store static routes (no params) in Map for O(1) lookup
122
- // Only store the first registration — "first registration wins" semantics
123
123
  if (parsed.params.length === 0) {
124
124
  const key = `${parsed.method} ${normalizePath(parsed.path)}`;
125
- if (!this.staticRoutes.has(key)) {
126
- this.staticRoutes.set(key, compiledRoute);
127
- }
125
+ this.staticRoutes.set(key, compiledRoute);
128
126
  }
129
127
  this.logger.log("route", `Route defined: ${route}`, {
130
128
  contentType: config.contentType,
@@ -419,7 +417,7 @@ export class CallableMockInstance {
419
417
  const response = parseResponse(result, matchedRoute.config);
420
418
  // Apply delay (route-level overrides global)
421
419
  await this.applyDelay(matchedRoute.config.delay);
422
- // Record request in history
420
+ // Record request in history (FIFO-bounded when maxHistorySize is set)
423
421
  this.requestHistory.push({
424
422
  method,
425
423
  path: requestPath,
@@ -430,6 +428,12 @@ export class CallableMockInstance {
430
428
  timestamp: Date.now(),
431
429
  response: { status: response.status, body: response.body },
432
430
  });
431
+ const maxHistorySize = this.globalConfig.maxHistorySize;
432
+ if (typeof maxHistorySize === "number" &&
433
+ maxHistorySize >= 0 &&
434
+ this.requestHistory.length > maxHistorySize) {
435
+ this.requestHistory.splice(0, this.requestHistory.length - maxHistorySize);
436
+ }
433
437
  this.emit("request:end", {
434
438
  method,
435
439
  path: requestPath,
@@ -16,6 +16,11 @@ export declare function isRouteNotFound(response: {
16
16
  /**
17
17
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
18
18
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
19
+ *
20
+ * Known ambiguity: a length-2 numeric array whose first element happens to
21
+ * be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
22
+ * indistinguishable from a status tuple by shape alone. Prefer the explicit
23
+ * status() helper or return an object response when the data could collide.
19
24
  */
20
25
  export declare function isStatusTuple(value: unknown): value is [number, unknown] | [number, unknown, Record<string, string>];
21
26
  //# sourceMappingURL=constants.d.ts.map
@@ -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,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,eAAe,CAAC,QAAQ,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CASV;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,eAAe,CAAC,QAAQ,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CASV;AAED;;;;;;;;GAQG;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
@@ -40,6 +40,11 @@ export function isRouteNotFound(response) {
40
40
  /**
41
41
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
42
42
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
43
+ *
44
+ * Known ambiguity: a length-2 numeric array whose first element happens to
45
+ * be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
46
+ * indistinguishable from a status tuple by shape alone. Prefer the explicit
47
+ * status() helper or return an object response when the data could collide.
43
48
  */
44
49
  export function isStatusTuple(value) {
45
50
  return (Array.isArray(value) &&
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAuC3D"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAyC3D"}
package/dist/parser.js CHANGED
@@ -17,20 +17,23 @@ export function parseRouteKey(routeKey) {
17
17
  throw new RouteParseError(routeKey, 'Expected format: "METHOD /path" (e.g., "GET /users")');
18
18
  }
19
19
  const [, method, path] = match;
20
- // Extract parameter names
20
+ // Parameter names are restricted to [A-Za-z0-9_-] so that surrounding
21
+ // literals (".json", brackets, parens, etc.) terminate the name and can
22
+ // be escaped without bleeding into the parameter regex. Build the
23
+ // pattern by splitting the path on the param marker, escaping each
24
+ // literal segment, then substituting the capture group for each :name.
21
25
  const params = [];
22
- const paramPattern = /:([^/]+)/g;
23
- let paramMatch;
24
- paramMatch = paramPattern.exec(path);
25
- while (paramMatch !== null) {
26
- params.push(paramMatch[1]);
27
- paramMatch = paramPattern.exec(path);
28
- }
29
- // Build regex pattern for matching
30
- // Replace :param with capture groups
31
26
  const regexPath = path
32
- .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars except :
33
- .replace(/:([^/]+)/g, "([^/]+)"); // Replace :param with capture group
27
+ .split(/(:[a-zA-Z0-9_-]+)/g)
28
+ .map((segment) => {
29
+ const paramMatch = segment.match(/^:([a-zA-Z0-9_-]+)$/);
30
+ if (paramMatch) {
31
+ params.push(paramMatch[1]);
32
+ return "([^/]+)";
33
+ }
34
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ })
36
+ .join("");
34
37
  const pattern = new RegExp(`^${regexPath}$`);
35
38
  return {
36
39
  method: toHttpMethod(method),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "2.0.1",
4
+ "version": "2.0.3",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -32,10 +32,10 @@
32
32
  },
33
33
  "license": "MIT",
34
34
  "devDependencies": {
35
- "@amiceli/vitest-cucumber": "^6.3.0",
35
+ "@amiceli/vitest-cucumber": "^6.5.0",
36
36
  "@types/json-schema": "^7.0.15",
37
- "@types/node": "^25.5.0",
38
- "@vitest/ui": "^4.1.2",
39
- "vitest": "^4.1.2"
37
+ "@types/node": "^25.8.0",
38
+ "@vitest/ui": "^4.1.6",
39
+ "vitest": "^4.1.6"
40
40
  }
41
41
  }
package/src/builder.ts CHANGED
@@ -129,7 +129,7 @@ export class CallableMockInstance {
129
129
  // Parse the route key to create pattern and extract parameters
130
130
  const parsed = parseRouteKey(route);
131
131
 
132
- // Check for duplicate routes
132
+ // Check for duplicate routes — first registration wins, subsequent ones are dropped
133
133
  const existing = this.routes.find(
134
134
  (r) => r.method === parsed.method && r.path === parsed.path,
135
135
  );
@@ -138,6 +138,7 @@ export class CallableMockInstance {
138
138
  "warning",
139
139
  `Duplicate route: ${route} — first registration wins`,
140
140
  );
141
+ return this;
141
142
  }
142
143
 
143
144
  // Compile the route
@@ -153,12 +154,9 @@ export class CallableMockInstance {
153
154
  this.routes.push(compiledRoute);
154
155
 
155
156
  // Store static routes (no params) in Map for O(1) lookup
156
- // Only store the first registration — "first registration wins" semantics
157
157
  if (parsed.params.length === 0) {
158
158
  const key = `${parsed.method} ${normalizePath(parsed.path)}`;
159
- if (!this.staticRoutes.has(key)) {
160
- this.staticRoutes.set(key, compiledRoute);
161
- }
159
+ this.staticRoutes.set(key, compiledRoute);
162
160
  }
163
161
 
164
162
  this.logger.log("route", `Route defined: ${route}`, {
@@ -564,7 +562,7 @@ export class CallableMockInstance {
564
562
  // Apply delay (route-level overrides global)
565
563
  await this.applyDelay(matchedRoute.config.delay);
566
564
 
567
- // Record request in history
565
+ // Record request in history (FIFO-bounded when maxHistorySize is set)
568
566
  this.requestHistory.push({
569
567
  method,
570
568
  path: requestPath,
@@ -575,6 +573,17 @@ export class CallableMockInstance {
575
573
  timestamp: Date.now(),
576
574
  response: { status: response.status, body: response.body },
577
575
  });
576
+ const maxHistorySize = this.globalConfig.maxHistorySize;
577
+ if (
578
+ typeof maxHistorySize === "number" &&
579
+ maxHistorySize >= 0 &&
580
+ this.requestHistory.length > maxHistorySize
581
+ ) {
582
+ this.requestHistory.splice(
583
+ 0,
584
+ this.requestHistory.length - maxHistorySize,
585
+ );
586
+ }
578
587
 
579
588
  this.emit("request:end", {
580
589
  method,
package/src/constants.ts CHANGED
@@ -54,6 +54,11 @@ export function isRouteNotFound(response: {
54
54
  /**
55
55
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
56
56
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
57
+ *
58
+ * Known ambiguity: a length-2 numeric array whose first element happens to
59
+ * be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
60
+ * indistinguishable from a status tuple by shape alone. Prefer the explicit
61
+ * status() helper or return an object response when the data could collide.
57
62
  */
58
63
  export function isStatusTuple(
59
64
  value: unknown,
@@ -127,5 +127,22 @@ describe("parseRouteKey", () => {
127
127
  expect(match).toBeTruthy();
128
128
  expect(match?.[1]).toBe("report-2023.pdf");
129
129
  });
130
+
131
+ it("stops the param name at a literal '.' suffix and escapes the dot", () => {
132
+ const route = parseRouteKey("GET /files/:name.json");
133
+
134
+ expect(route.params).toEqual(["name"]);
135
+ expect("/files/report.json".match(route.pattern)?.[1]).toBe("report");
136
+ expect("/files/report.json2".match(route.pattern)).toBeNull();
137
+ expect("/files/reportXjson".match(route.pattern)).toBeNull();
138
+ });
139
+
140
+ it("treats parens around params as literal characters", () => {
141
+ const route = parseRouteKey("GET /items/(:id)");
142
+
143
+ expect(route.params).toEqual(["id"]);
144
+ expect("/items/(42)".match(route.pattern)?.[1]).toBe("42");
145
+ expect("/items/42".match(route.pattern)).toBeNull();
146
+ });
130
147
  });
131
148
  });
package/src/parser.ts CHANGED
@@ -34,22 +34,24 @@ export function parseRouteKey(routeKey: string): ParsedRoute {
34
34
 
35
35
  const [, method, path] = match;
36
36
 
37
- // Extract parameter names
37
+ // Parameter names are restricted to [A-Za-z0-9_-] so that surrounding
38
+ // literals (".json", brackets, parens, etc.) terminate the name and can
39
+ // be escaped without bleeding into the parameter regex. Build the
40
+ // pattern by splitting the path on the param marker, escaping each
41
+ // literal segment, then substituting the capture group for each :name.
38
42
  const params: string[] = [];
39
- const paramPattern = /:([^/]+)/g;
40
- let paramMatch: RegExpExecArray | null;
41
43
 
42
- paramMatch = paramPattern.exec(path);
43
- while (paramMatch !== null) {
44
- params.push(paramMatch[1]);
45
- paramMatch = paramPattern.exec(path);
46
- }
47
-
48
- // Build regex pattern for matching
49
- // Replace :param with capture groups
50
44
  const regexPath = path
51
- .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars except :
52
- .replace(/:([^/]+)/g, "([^/]+)"); // Replace :param with capture group
45
+ .split(/(:[a-zA-Z0-9_-]+)/g)
46
+ .map((segment) => {
47
+ const paramMatch = segment.match(/^:([a-zA-Z0-9_-]+)$/);
48
+ if (paramMatch) {
49
+ params.push(paramMatch[1]);
50
+ return "([^/]+)";
51
+ }
52
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ })
54
+ .join("");
53
55
 
54
56
  const pattern = new RegExp(`^${regexPath}$`);
55
57
 
@@ -183,4 +183,22 @@ describeFeature(feature, ({ Scenario }) => {
183
183
  });
184
184
  });
185
185
 
186
+ Scenario("Duplicate routes are not duplicated in getRoutes()", ({ Given, When, Then }) => {
187
+ let routesList: ReturnType<typeof mock.getRoutes>;
188
+
189
+ Given("I create a mock with two routes on {string} with different data", (_, route: string) => {
190
+ mock = schmock();
191
+ mock(route as Schmock.RouteKey, [{ id: 1 }]);
192
+ mock(route as Schmock.RouteKey, [{ id: 2 }]);
193
+ });
194
+
195
+ When("I call getRoutes", () => {
196
+ routesList = mock.getRoutes();
197
+ });
198
+
199
+ Then("only one route entry is returned", () => {
200
+ expect(routesList).toHaveLength(1);
201
+ });
202
+ });
203
+
186
204
  });
@@ -255,4 +255,22 @@ describeFeature(feature, ({ Scenario }) => {
255
255
  expect(mock.callCount()).toBe(0);
256
256
  });
257
257
  });
258
+
259
+ Scenario("maxHistorySize bounds the history with FIFO eviction", ({ Given, When, Then }) => {
260
+ Given("I create a mock with maxHistorySize {int} and a users route", (_, maxHistorySize: number) => {
261
+ mock = schmock({ maxHistorySize });
262
+ mock("GET /users", []);
263
+ });
264
+
265
+ When("I issue {int} requests to {string}", async (_, count: number, request: string) => {
266
+ const [method, path] = request.split(" ");
267
+ for (let i = 0; i < count; i++) {
268
+ await mock.handle(method as any, path);
269
+ }
270
+ });
271
+
272
+ Then("the call count should be {int}", (_, expected: number) => {
273
+ expect(mock.callCount()).toBe(expected);
274
+ });
275
+ });
258
276
  });