@schmock/core 2.0.0 → 2.0.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.
@@ -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;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;IAYb,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;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"}
package/dist/builder.js CHANGED
@@ -210,6 +210,8 @@ export class CallableMockInstance {
210
210
  }
211
211
  // ===== Reset / Lifecycle =====
212
212
  reset() {
213
+ this.interceptHandle?.restore();
214
+ this.interceptHandle = null;
213
215
  this.close();
214
216
  this.routes = [];
215
217
  this.staticRoutes.clear();
@@ -281,6 +283,7 @@ export class CallableMockInstance {
281
283
  if (!this.server) {
282
284
  return;
283
285
  }
286
+ this.server.closeAllConnections();
284
287
  this.server.close();
285
288
  this.server = undefined;
286
289
  this.serverInfo = undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AAwGA;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,CACN,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,cAAc,CAAC,EAAE,OAAO,CAAC,cAAc,KACpC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAC9B,OAAO,GAAE,OAAO,CAAC,gBAAqB,GACrC,OAAO,CAAC,eAAe,CAyIzB"}
1
+ {"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AAoHA;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,CACN,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,cAAc,CAAC,EAAE,OAAO,CAAC,cAAc,KACpC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAC9B,OAAO,GAAE,OAAO,CAAC,gBAAqB,GACrC,OAAO,CAAC,eAAe,CAiJzB"}
@@ -38,7 +38,7 @@ function extractQuery(url) {
38
38
  */
39
39
  function extractHeaders(input, init) {
40
40
  const headers = {};
41
- const raw = input instanceof Request ? input.headers : init?.headers;
41
+ const raw = init?.headers ?? (input instanceof Request ? input.headers : undefined);
42
42
  if (!raw)
43
43
  return headers;
44
44
  if (raw instanceof Headers) {
@@ -48,11 +48,13 @@ function extractHeaders(input, init) {
48
48
  }
49
49
  else if (Array.isArray(raw)) {
50
50
  for (const [key, value] of raw) {
51
- headers[key] = value;
51
+ headers[key.toLowerCase()] = value;
52
52
  }
53
53
  }
54
54
  else {
55
- Object.assign(headers, raw);
55
+ for (const key of Object.keys(raw)) {
56
+ headers[key.toLowerCase()] = raw[key];
57
+ }
56
58
  }
57
59
  return headers;
58
60
  }
@@ -60,11 +62,11 @@ function extractHeaders(input, init) {
60
62
  * Extract body from fetch init, parsing JSON when possible.
61
63
  */
62
64
  async function extractBody(input, init) {
63
- const rawBody = input instanceof Request ? input.body : init?.body;
64
- if (rawBody === null || rawBody === undefined)
65
+ // Per Fetch spec, init.body overrides Request.body when both are present
66
+ const bodyInit = init?.body ?? (input instanceof Request ? input.body : null);
67
+ if (bodyInit === null || bodyInit === undefined)
65
68
  return undefined;
66
69
  // String body — try to parse as JSON
67
- const bodyInit = init?.body;
68
70
  if (typeof bodyInit === "string") {
69
71
  try {
70
72
  return JSON.parse(bodyInit);
@@ -73,8 +75,16 @@ async function extractBody(input, init) {
73
75
  return bodyInit;
74
76
  }
75
77
  }
78
+ // URLSearchParams — convert to key/value object
79
+ if (bodyInit instanceof URLSearchParams) {
80
+ const result = {};
81
+ bodyInit.forEach((value, key) => {
82
+ result[key] = value;
83
+ });
84
+ return result;
85
+ }
76
86
  // Request with body — clone and read
77
- if (input instanceof Request && input.body) {
87
+ if (input instanceof Request && !init?.body && input.body) {
78
88
  try {
79
89
  return await input.clone().json();
80
90
  }
@@ -105,8 +115,15 @@ export function createFetchInterceptor(handle, options = {}) {
105
115
  : input;
106
116
  const path = extractPathname(urlString);
107
117
  // BaseUrl filter — non-matching requests go straight to real fetch
108
- if (baseUrl && !path.startsWith(baseUrl)) {
109
- return originalFetch(input, init);
118
+ // Enforce segment boundary: /api must not match /apiv2
119
+ if (baseUrl) {
120
+ const normalizedBase = baseUrl.endsWith("/")
121
+ ? baseUrl.slice(0, -1)
122
+ : baseUrl;
123
+ const isMatch = path === normalizedBase || path.startsWith(`${normalizedBase}/`);
124
+ if (!isMatch) {
125
+ return originalFetch(input, init);
126
+ }
110
127
  }
111
128
  // Build adapter request
112
129
  const method = input instanceof Request ? input.method : (init?.method ?? "GET");
package/dist/types.d.ts CHANGED
@@ -30,4 +30,5 @@ export type AdapterRequest = Schmock.AdapterRequest;
30
30
  export type AdapterResponse = Schmock.AdapterResponse;
31
31
  export type InterceptOptions = Schmock.InterceptOptions;
32
32
  export type InterceptHandle = Schmock.InterceptHandle;
33
+ export type AdapterRequestOverride = Schmock.AdapterRequestOverride;
33
34
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;AAC9C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;AAC1D,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;AAChE,MAAM,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AACpC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,uBAAuB,GAAG,OAAO,CAAC,uBAAuB,CAAC;AACtE,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;AAC5D,MAAM,MAAM,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC;AAClE,MAAM,MAAM,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC;AAClE,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;AACtD,MAAM,MAAM,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC;AACxD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;AAC9C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;AAC1D,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;AAChE,MAAM,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AACpC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,uBAAuB,GAAG,OAAO,CAAC,uBAAuB,CAAC;AACtE,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;AAC5D,MAAM,MAAM,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC;AAClE,MAAM,MAAM,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC;AAClE,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;AACtD,MAAM,MAAM,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC;AACxD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;AACtD,MAAM,MAAM,sBAAsB,GAAG,OAAO,CAAC,sBAAsB,CAAC"}
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.0",
4
+ "version": "2.0.1",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
package/src/builder.ts CHANGED
@@ -283,6 +283,8 @@ export class CallableMockInstance {
283
283
  // ===== Reset / Lifecycle =====
284
284
 
285
285
  reset(): void {
286
+ this.interceptHandle?.restore();
287
+ this.interceptHandle = null;
286
288
  this.close();
287
289
  this.routes = [];
288
290
  this.staticRoutes.clear();
@@ -370,6 +372,7 @@ export class CallableMockInstance {
370
372
  if (!this.server) {
371
373
  return;
372
374
  }
375
+ this.server.closeAllConnections();
373
376
  this.server.close();
374
377
  this.server = undefined;
375
378
  this.serverInfo = undefined;
@@ -103,4 +103,45 @@ describe("paginate", () => {
103
103
  expect(result.data).toEqual([]);
104
104
  expect(result.total).toBe(5);
105
105
  });
106
+
107
+ it("page=0 falls back to page 1 (falsy default)", () => {
108
+ const result = paginate(items, { page: 0, pageSize: 2 });
109
+ // page=0 is falsy, so options.page || 1 => 1
110
+ expect(result.page).toBe(1);
111
+ expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
112
+ });
113
+
114
+ it("pageSize=0 falls back to default 10 (falsy default)", () => {
115
+ const result = paginate(items, { pageSize: 0 });
116
+ // pageSize=0 is falsy, so options.pageSize || 10 => 10
117
+ expect(result.pageSize).toBe(10);
118
+ expect(result.data).toEqual(items);
119
+ });
120
+
121
+ it("handles empty array", () => {
122
+ const result = paginate([], { page: 1, pageSize: 5 });
123
+ expect(result).toEqual({
124
+ data: [],
125
+ page: 1,
126
+ pageSize: 5,
127
+ total: 0,
128
+ totalPages: 0,
129
+ });
130
+ });
131
+
132
+ it("negative page produces empty data (start index < 0)", () => {
133
+ const result = paginate(items, { page: -1, pageSize: 2 });
134
+ // (page - 1) * pageSize = (-1 - 1) * 2 = -4
135
+ // items.slice(-4, -2) => items from index 1 to 3
136
+ expect(result.page).toBe(-1);
137
+ expect(result.pageSize).toBe(2);
138
+ expect(result.total).toBe(5);
139
+ });
140
+
141
+ it("negative pageSize returns empty data", () => {
142
+ const result = paginate(items, { page: 1, pageSize: -5 });
143
+ // start = 0, end = 0 + (-5) = -5 => items.slice(0, -5) => []
144
+ expect(result.data).toEqual([]);
145
+ expect(result.pageSize).toBe(-5);
146
+ });
106
147
  });
@@ -143,6 +143,29 @@ describe("mock.intercept()", () => {
143
143
  handle.restore();
144
144
  });
145
145
 
146
+ it("normalizes header keys to lowercase", async () => {
147
+ mock("POST /api/data", ({ headers }) => [
148
+ 200,
149
+ { auth: headers.authorization, ct: headers["content-type"] },
150
+ ]);
151
+ const handle = mock.intercept();
152
+
153
+ const res = await fetch("http://localhost/api/data", {
154
+ method: "POST",
155
+ headers: {
156
+ Authorization: "Bearer tok",
157
+ "Content-Type": "application/json",
158
+ },
159
+ body: JSON.stringify({}),
160
+ });
161
+ expect(await res.json()).toEqual({
162
+ auth: "Bearer tok",
163
+ ct: "application/json",
164
+ });
165
+
166
+ handle.restore();
167
+ });
168
+
146
169
  it("parses JSON body from fetch init", async () => {
147
170
  mock("POST /api/users", ({ body }) => [201, body]);
148
171
  const handle = mock.intercept();
@@ -157,4 +180,112 @@ describe("mock.intercept()", () => {
157
180
 
158
181
  handle.restore();
159
182
  });
183
+
184
+ it("intercepts fetch called with a Request object", async () => {
185
+ mock("GET /api/users", [{ id: 1, name: "Alice" }]);
186
+ const handle = mock.intercept();
187
+
188
+ const req = new Request("http://localhost/api/users");
189
+ const res = await fetch(req);
190
+ expect(res.status).toBe(200);
191
+ expect(await res.json()).toEqual([{ id: 1, name: "Alice" }]);
192
+
193
+ handle.restore();
194
+ });
195
+
196
+ it("prefers init.headers over Request.headers", async () => {
197
+ mock("GET /api/data", ({ headers }) => [200, { val: headers["x-custom"] }]);
198
+ const handle = mock.intercept();
199
+
200
+ const req = new Request("http://localhost/api/data", {
201
+ headers: { "X-Custom": "from-request" },
202
+ });
203
+ const res = await fetch(req, {
204
+ headers: { "X-Custom": "from-init" },
205
+ });
206
+ expect(await res.json()).toEqual({ val: "from-init" });
207
+
208
+ handle.restore();
209
+ });
210
+
211
+ it("parses URLSearchParams body", async () => {
212
+ mock("POST /api/form", ({ body }) => [200, body]);
213
+ const handle = mock.intercept();
214
+
215
+ const params = new URLSearchParams();
216
+ params.set("name", "Alice");
217
+ params.set("role", "admin");
218
+
219
+ const res = await fetch("http://localhost/api/form", {
220
+ method: "POST",
221
+ body: params,
222
+ });
223
+ expect(await res.json()).toEqual({ name: "Alice", role: "admin" });
224
+
225
+ handle.restore();
226
+ });
227
+
228
+ it("baseUrl /api should NOT match /apiv2 (segment boundary)", async () => {
229
+ mock("GET /apiv2/data", [{ v2: true }]);
230
+ const savedFetch = globalThis.fetch;
231
+ const handle = mock.intercept({ baseUrl: "/api" });
232
+
233
+ // /apiv2/data does not start with "/api/" — it should passthrough
234
+ await fetch("http://localhost/apiv2/data");
235
+ expect(savedFetch).toHaveBeenCalled();
236
+
237
+ handle.restore();
238
+ });
239
+
240
+ it("baseUrl with trailing slash matches the same routes as without", async () => {
241
+ mock("GET /api/items", [{ id: 1 }]);
242
+
243
+ // Without trailing slash
244
+ const handle1 = mock.intercept({ baseUrl: "/api" });
245
+ const res1 = await fetch("http://localhost/api/items");
246
+ expect(res1.status).toBe(200);
247
+ expect(await res1.json()).toEqual([{ id: 1 }]);
248
+ handle1.restore();
249
+
250
+ // With trailing slash — should still match /api/items
251
+ const handle2 = mock.intercept({ baseUrl: "/api/" });
252
+ const res2 = await fetch("http://localhost/api/items");
253
+ expect(res2.status).toBe(200);
254
+ expect(await res2.json()).toEqual([{ id: 1 }]);
255
+ handle2.restore();
256
+ });
257
+
258
+ it("extractBody: init.body wins over Request.body per Fetch spec", async () => {
259
+ mock("POST /api/data", ({ body }) => [200, body]);
260
+ const handle = mock.intercept();
261
+
262
+ const req = new Request("http://localhost/api/data", {
263
+ method: "POST",
264
+ headers: { "content-type": "application/json" },
265
+ body: JSON.stringify({ source: "request" }),
266
+ });
267
+
268
+ const res = await fetch(req, {
269
+ body: JSON.stringify({ source: "init" }),
270
+ });
271
+ expect(await res.json()).toEqual({ source: "init" });
272
+
273
+ handle.restore();
274
+ });
275
+
276
+ it("extractBody: failed JSON parse falls back to text", async () => {
277
+ mock("POST /api/text", ({ body }) => [200, { received: body }]);
278
+ const handle = mock.intercept();
279
+
280
+ const res = await fetch("http://localhost/api/text", {
281
+ method: "POST",
282
+ headers: { "content-type": "text/plain" },
283
+ body: "not-json-{broken",
284
+ });
285
+
286
+ const json = await res.json();
287
+ expect(json.received).toBe("not-json-{broken");
288
+
289
+ handle.restore();
290
+ });
160
291
  });
@@ -48,7 +48,8 @@ function extractHeaders(
48
48
  ): Record<string, string> {
49
49
  const headers: Record<string, string> = {};
50
50
 
51
- const raw = input instanceof Request ? input.headers : init?.headers;
51
+ const raw =
52
+ init?.headers ?? (input instanceof Request ? input.headers : undefined);
52
53
  if (!raw) return headers;
53
54
 
54
55
  if (raw instanceof Headers) {
@@ -57,10 +58,12 @@ function extractHeaders(
57
58
  });
58
59
  } else if (Array.isArray(raw)) {
59
60
  for (const [key, value] of raw) {
60
- headers[key] = value;
61
+ headers[key.toLowerCase()] = value;
61
62
  }
62
63
  } else {
63
- Object.assign(headers, raw);
64
+ for (const key of Object.keys(raw)) {
65
+ headers[key.toLowerCase()] = (raw as Record<string, string>)[key];
66
+ }
64
67
  }
65
68
 
66
69
  return headers;
@@ -73,11 +76,11 @@ async function extractBody(
73
76
  input: RequestInfo | URL,
74
77
  init?: RequestInit,
75
78
  ): Promise<unknown> {
76
- const rawBody = input instanceof Request ? input.body : init?.body;
77
- if (rawBody === null || rawBody === undefined) return undefined;
79
+ // Per Fetch spec, init.body overrides Request.body when both are present
80
+ const bodyInit = init?.body ?? (input instanceof Request ? input.body : null);
81
+ if (bodyInit === null || bodyInit === undefined) return undefined;
78
82
 
79
83
  // String body — try to parse as JSON
80
- const bodyInit = init?.body;
81
84
  if (typeof bodyInit === "string") {
82
85
  try {
83
86
  return JSON.parse(bodyInit);
@@ -86,8 +89,17 @@ async function extractBody(
86
89
  }
87
90
  }
88
91
 
92
+ // URLSearchParams — convert to key/value object
93
+ if (bodyInit instanceof URLSearchParams) {
94
+ const result: Record<string, string> = {};
95
+ bodyInit.forEach((value, key) => {
96
+ result[key] = value;
97
+ });
98
+ return result;
99
+ }
100
+
89
101
  // Request with body — clone and read
90
- if (input instanceof Request && input.body) {
102
+ if (input instanceof Request && !init?.body && input.body) {
91
103
  try {
92
104
  return await input.clone().json();
93
105
  } catch {
@@ -139,8 +151,16 @@ export function createFetchInterceptor(
139
151
  const path = extractPathname(urlString);
140
152
 
141
153
  // BaseUrl filter — non-matching requests go straight to real fetch
142
- if (baseUrl && !path.startsWith(baseUrl)) {
143
- return originalFetch(input, init);
154
+ // Enforce segment boundary: /api must not match /apiv2
155
+ if (baseUrl) {
156
+ const normalizedBase = baseUrl.endsWith("/")
157
+ ? baseUrl.slice(0, -1)
158
+ : baseUrl;
159
+ const isMatch =
160
+ path === normalizedBase || path.startsWith(`${normalizedBase}/`);
161
+ if (!isMatch) {
162
+ return originalFetch(input, init);
163
+ }
144
164
  }
145
165
 
146
166
  // Build adapter request
@@ -491,3 +491,104 @@ describe("handle() — fuzz", () => {
491
491
  );
492
492
  });
493
493
  });
494
+
495
+ // ============================================================
496
+ // Route matching round-trip properties
497
+ // ============================================================
498
+
499
+ describe("route matching round-trip — property", () => {
500
+ it("parameterized route: arbitrary param values are extracted correctly", () => {
501
+ fc.assert(
502
+ fc.asyncProperty(
503
+ safeSeg,
504
+ safeSeg,
505
+ safeSeg,
506
+ async (resource, paramA, paramB) => {
507
+ const mock = schmock();
508
+ let capturedParams: Record<string, string> = {};
509
+ mock(`GET /${resource}/:a/:b` as any, (ctx) => {
510
+ capturedParams = ctx.params;
511
+ return { ok: true };
512
+ });
513
+
514
+ const response = await mock.handle(
515
+ "GET",
516
+ `/${resource}/${paramA}/${paramB}`,
517
+ );
518
+ expect(response.status).toBe(200);
519
+ expect(capturedParams.a).toBe(paramA);
520
+ expect(capturedParams.b).toBe(paramB);
521
+ },
522
+ ),
523
+ { numRuns: 300 },
524
+ );
525
+ });
526
+
527
+ it("any valid response format produces a valid Response with status/body/headers", () => {
528
+ const responseGen = fc.oneof(
529
+ // Object body
530
+ fc.record({ key: safeSeg }),
531
+ // Tuple [status, body]
532
+ fc.tuple(fc.integer({ min: 100, max: 599 }), fc.record({ v: safeSeg })),
533
+ // Tuple [status, body, headers]
534
+ fc.tuple(
535
+ fc.integer({ min: 100, max: 599 }),
536
+ fc.record({ v: safeSeg }),
537
+ fc.constant({}),
538
+ ),
539
+ // null/undefined
540
+ fc.constant(null),
541
+ fc.constant(undefined),
542
+ );
543
+
544
+ fc.assert(
545
+ fc.asyncProperty(responseGen, async (genValue) => {
546
+ const mock = schmock();
547
+ mock("GET /test", () => genValue);
548
+
549
+ const response = await mock.handle("GET", "/test");
550
+ expect(typeof response.status).toBe("number");
551
+ expect(response.status).toBeGreaterThanOrEqual(100);
552
+ expect(response.status).toBeLessThanOrEqual(599);
553
+ expect(response).toHaveProperty("headers");
554
+ expect(typeof response.headers).toBe("object");
555
+ }),
556
+ { numRuns: 300 },
557
+ );
558
+ });
559
+
560
+ it("header keys are always lowercased after extractHeaders in interceptor", () => {
561
+ const headerKey = fc
562
+ .stringMatching(/^[A-Za-z][A-Za-z0-9-]{0,20}$/)
563
+ .filter((k) => k.length > 0);
564
+ const headerVal = safeSeg;
565
+
566
+ fc.assert(
567
+ fc.asyncProperty(headerKey, headerVal, async (key, val) => {
568
+ const mock = schmock();
569
+ let capturedHeaders: Record<string, string> = {};
570
+ mock("GET /hdr", (ctx) => {
571
+ capturedHeaders = ctx.headers;
572
+ return { ok: true };
573
+ });
574
+ const handle = mock.intercept();
575
+
576
+ try {
577
+ await fetch("http://localhost/hdr", {
578
+ headers: { [key]: val },
579
+ });
580
+
581
+ // All header keys should be lowercase
582
+ for (const hdrKey of Object.keys(capturedHeaders)) {
583
+ expect(hdrKey).toBe(hdrKey.toLowerCase());
584
+ }
585
+ // The value should be preserved
586
+ expect(capturedHeaders[key.toLowerCase()]).toBe(val);
587
+ } finally {
588
+ handle.restore();
589
+ }
590
+ }),
591
+ { numRuns: 200 },
592
+ );
593
+ });
594
+ });
@@ -225,6 +225,80 @@ describe("response parsing", () => {
225
225
  });
226
226
  });
227
227
 
228
+ describe("status code boundaries", () => {
229
+ it("status code 100 (lower boundary) is accepted as tuple", async () => {
230
+ const mock = schmock();
231
+ mock("GET /continue", () => [100, "Continue"]);
232
+
233
+ const response = await mock.handle("GET", "/continue");
234
+ expect(response.status).toBe(100);
235
+ expect(response.body).toBe("Continue");
236
+ });
237
+
238
+ it("status code 599 (upper boundary) is accepted as tuple", async () => {
239
+ const mock = schmock();
240
+ mock("GET /custom", () => [599, { message: "custom status" }]);
241
+
242
+ const response = await mock.handle("GET", "/custom");
243
+ expect(response.status).toBe(599);
244
+ expect(response.body).toEqual({ message: "custom status" });
245
+ });
246
+
247
+ it("float status like 200.5 is accepted as tuple (number check)", async () => {
248
+ const mock = schmock();
249
+ mock("GET /float", () => [200.5, { ok: true }]);
250
+
251
+ const response = await mock.handle("GET", "/float");
252
+ // isStatusTuple checks typeof === 'number' and 100 <= n <= 599
253
+ // 200.5 passes both checks, so it's treated as a tuple
254
+ expect(response.status).toBe(200.5);
255
+ expect(response.body).toEqual({ ok: true });
256
+ });
257
+
258
+ it("array [201, null, undefined] — tuple with undefined headers defaults to empty", async () => {
259
+ const mock = schmock();
260
+ mock("GET /null-headers", () => [201, null, undefined] as any);
261
+
262
+ const response = await mock.handle("GET", "/null-headers");
263
+ // isStatusTuple: length 3, first element 201 (number, 100-599) -> true
264
+ // destructure: [status=201, body=null, headers=undefined]
265
+ // headers defaults to {} via `headers = {}`
266
+ expect(response.status).toBe(201);
267
+ expect(response.body).toBeUndefined(); // null body becomes undefined
268
+ expect(response.headers).toEqual({});
269
+ });
270
+
271
+ it("non-tuple array [1, 2, 3, 4] with length > 3 treated as body", async () => {
272
+ const mock = schmock();
273
+ mock("GET /long-array", () => [1, 2, 3, 4] as any);
274
+
275
+ const response = await mock.handle("GET", "/long-array");
276
+ // isStatusTuple requires length 2 or 3, so length 4 => not a tuple => body
277
+ expect(response.status).toBe(200);
278
+ expect(response.body).toEqual([1, 2, 3, 4]);
279
+ });
280
+
281
+ it("status 99 (below 100) is NOT treated as a tuple", async () => {
282
+ const mock = schmock();
283
+ mock("GET /below-range", () => [99, { data: true }]);
284
+
285
+ const response = await mock.handle("GET", "/below-range");
286
+ // isStatusTuple requires value[0] >= 100, so 99 fails => treated as body
287
+ expect(response.status).toBe(200);
288
+ expect(response.body).toEqual([99, { data: true }]);
289
+ });
290
+
291
+ it("status 600 (above 599) is NOT treated as a tuple", async () => {
292
+ const mock = schmock();
293
+ mock("GET /above-range", () => [600, { data: true }]);
294
+
295
+ const response = await mock.handle("GET", "/above-range");
296
+ // isStatusTuple requires value[0] <= 599, so 600 fails => treated as body
297
+ expect(response.status).toBe(200);
298
+ expect(response.body).toEqual([600, { data: true }]);
299
+ });
300
+ });
301
+
228
302
  describe("edge cases", () => {
229
303
  it("handles response with circular references gracefully", async () => {
230
304
  const mock = schmock();
@@ -113,4 +113,53 @@ describe("Standalone Server", () => {
113
113
  mock.close();
114
114
  expect(() => mock.close()).not.toThrow();
115
115
  });
116
+
117
+ it("generator that throws synchronously returns 500", async () => {
118
+ mock = schmock();
119
+ mock("GET /boom", () => {
120
+ throw new Error("sync kaboom");
121
+ });
122
+ const info = await mock.listen(0);
123
+
124
+ const res = await fetch(`http://127.0.0.1:${info.port}/boom`);
125
+ expect(res.status).toBe(500);
126
+ const body = await res.json();
127
+ expect(body.error).toBe("sync kaboom");
128
+ });
129
+
130
+ it("generator that returns rejected Promise returns 500", async () => {
131
+ mock = schmock();
132
+ mock("GET /reject", async () => {
133
+ throw new Error("async rejection");
134
+ });
135
+ const info = await mock.listen(0);
136
+
137
+ const res = await fetch(`http://127.0.0.1:${info.port}/reject`);
138
+ expect(res.status).toBe(500);
139
+ const body = await res.json();
140
+ expect(body.error).toBe("async rejection");
141
+ });
142
+
143
+ it("close terminates keep-alive connections immediately", async () => {
144
+ mock = schmock();
145
+ mock("GET /test", { ok: true });
146
+ const info = await mock.listen(0);
147
+
148
+ // Make a keep-alive request to establish a persistent connection
149
+ const res = await fetch(`http://127.0.0.1:${info.port}/test`, {
150
+ headers: { connection: "keep-alive" },
151
+ });
152
+ expect(res.status).toBe(200);
153
+
154
+ // Close the server — should terminate all connections
155
+ mock.close();
156
+
157
+ // Subsequent request should fail immediately (not hang on keep-alive)
158
+ try {
159
+ await fetch(`http://127.0.0.1:${info.port}/test`);
160
+ expect.unreachable("Should have thrown");
161
+ } catch {
162
+ // Expected: connection refused
163
+ }
164
+ });
116
165
  });