@schmock/core 2.2.0 → 2.2.2
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 +7 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +48 -20
- package/dist/plugin-pipeline.d.ts.map +1 -1
- package/dist/plugin-pipeline.js +6 -3
- package/dist/response-parser.d.ts.map +1 -1
- package/dist/response-parser.js +1 -0
- package/package.json +5 -5
- package/src/audit-core-builder.test.ts +218 -0
- package/src/audit-response-guard.test.ts +38 -0
- package/src/builder.ts +52 -22
- package/src/plugin-pipeline.ts +6 -3
- package/src/response-parser.ts +1 -0
- package/src/steps/audit-core-builder.steps.ts +188 -0
- package/src/steps/audit-onerror-tuple.steps.ts +99 -0
- package/src/steps/performance-reliability.steps.ts +3 -0
- package/src/steps/response-delay.steps.ts +5 -2
package/dist/builder.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export declare class CallableMockInstance {
|
|
|
20
20
|
defineRoute(route: Schmock.RouteKey, generator: Schmock.Generator, config: Schmock.RouteConfig): this;
|
|
21
21
|
setCallableRef(ref: Schmock.CallableMockInstance): void;
|
|
22
22
|
pipe(plugin: Schmock.Plugin): this;
|
|
23
|
+
/**
|
|
24
|
+
* FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
|
|
25
|
+
* history by mutating nested body/response objects.
|
|
26
|
+
* Falls back to a shallow spread for bodies that are not structuredClone-able
|
|
27
|
+
* (e.g. Buffers, streams — rare in practice but defensively handled).
|
|
28
|
+
*/
|
|
29
|
+
private cloneRecord;
|
|
23
30
|
history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[];
|
|
24
31
|
called(method?: Schmock.HttpMethod, path?: string): boolean;
|
|
25
32
|
callCount(method?: Schmock.HttpMethod, path?: string): number;
|
package/dist/builder.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;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;
|
|
1
|
+
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;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;IAsFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAWnB,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAW5E,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;IAgBpC,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;IAgErE,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
|
@@ -69,30 +69,32 @@ export class CallableMockInstance {
|
|
|
69
69
|
}
|
|
70
70
|
// Method for defining routes (called when instance is invoked)
|
|
71
71
|
defineRoute(route, generator, config) {
|
|
72
|
+
// FIX 1.2: shallow-clone the caller's config so mutations below stay private
|
|
73
|
+
const routeConfig = { ...config };
|
|
72
74
|
// Auto-detect contentType if not provided
|
|
73
|
-
if (!
|
|
75
|
+
if (!routeConfig.contentType) {
|
|
74
76
|
if (typeof generator === "function") {
|
|
75
77
|
// Default to JSON for function generators
|
|
76
|
-
|
|
78
|
+
routeConfig.contentType = "application/json";
|
|
77
79
|
}
|
|
78
80
|
else if (typeof generator === "string" ||
|
|
79
81
|
typeof generator === "number" ||
|
|
80
82
|
typeof generator === "boolean") {
|
|
81
83
|
// Default to plain text for primitives
|
|
82
|
-
|
|
84
|
+
routeConfig.contentType = "text/plain";
|
|
83
85
|
}
|
|
84
86
|
else if (Buffer.isBuffer(generator)) {
|
|
85
87
|
// Default to octet-stream for buffers
|
|
86
|
-
|
|
88
|
+
routeConfig.contentType = "application/octet-stream";
|
|
87
89
|
}
|
|
88
90
|
else {
|
|
89
91
|
// Default to JSON for objects/arrays
|
|
90
|
-
|
|
92
|
+
routeConfig.contentType = "application/json";
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
// Validate generator matches contentType if it's static data
|
|
94
96
|
if (typeof generator !== "function" &&
|
|
95
|
-
|
|
97
|
+
routeConfig.contentType === "application/json") {
|
|
96
98
|
try {
|
|
97
99
|
JSON.stringify(generator);
|
|
98
100
|
}
|
|
@@ -102,8 +104,11 @@ export class CallableMockInstance {
|
|
|
102
104
|
}
|
|
103
105
|
// Parse the route key to create pattern and extract parameters
|
|
104
106
|
const parsed = parseRouteKey(route);
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
+
// FIX 2.2: normalize paths before duplicate check so /users and /users/ are
|
|
108
|
+
// treated as the same route (consistent with the static-route Map key below)
|
|
109
|
+
const normalizedParsedPath = normalizePath(parsed.path);
|
|
110
|
+
const existing = this.routes.find((r) => r.method === parsed.method &&
|
|
111
|
+
normalizePath(r.path) === normalizedParsedPath);
|
|
107
112
|
if (existing) {
|
|
108
113
|
this.logger.log("warning", `Duplicate route: ${route} — first registration wins`);
|
|
109
114
|
return this;
|
|
@@ -115,7 +120,7 @@ export class CallableMockInstance {
|
|
|
115
120
|
method: parsed.method,
|
|
116
121
|
path: parsed.path,
|
|
117
122
|
generator,
|
|
118
|
-
config,
|
|
123
|
+
config: routeConfig,
|
|
119
124
|
};
|
|
120
125
|
this.routes.push(compiledRoute);
|
|
121
126
|
// Store static routes (no params) in Map for O(1) lookup
|
|
@@ -124,7 +129,7 @@ export class CallableMockInstance {
|
|
|
124
129
|
this.staticRoutes.set(key, compiledRoute);
|
|
125
130
|
}
|
|
126
131
|
this.logger.log("route", `Route defined: ${route}`, {
|
|
127
|
-
contentType:
|
|
132
|
+
contentType: routeConfig.contentType,
|
|
128
133
|
generatorType: typeof generator,
|
|
129
134
|
hasParams: parsed.params.length > 0,
|
|
130
135
|
});
|
|
@@ -147,11 +152,30 @@ export class CallableMockInstance {
|
|
|
147
152
|
return this;
|
|
148
153
|
}
|
|
149
154
|
// ===== Request Spy / History API =====
|
|
155
|
+
/**
|
|
156
|
+
* FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
|
|
157
|
+
* history by mutating nested body/response objects.
|
|
158
|
+
* Falls back to a shallow spread for bodies that are not structuredClone-able
|
|
159
|
+
* (e.g. Buffers, streams — rare in practice but defensively handled).
|
|
160
|
+
*/
|
|
161
|
+
cloneRecord(r) {
|
|
162
|
+
try {
|
|
163
|
+
return structuredClone(r);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return {
|
|
167
|
+
...r,
|
|
168
|
+
response: { ...r.response },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
150
172
|
history(method, path) {
|
|
151
173
|
if (method || path) {
|
|
152
|
-
return this.requestHistory
|
|
174
|
+
return this.requestHistory
|
|
175
|
+
.filter((r) => (!method || r.method === method) && (!path || r.path === path))
|
|
176
|
+
.map((r) => this.cloneRecord(r));
|
|
153
177
|
}
|
|
154
|
-
return
|
|
178
|
+
return this.requestHistory.map((r) => this.cloneRecord(r));
|
|
155
179
|
}
|
|
156
180
|
called(method, path) {
|
|
157
181
|
if (method || path) {
|
|
@@ -168,9 +192,13 @@ export class CallableMockInstance {
|
|
|
168
192
|
lastRequest(method, path) {
|
|
169
193
|
if (method || path) {
|
|
170
194
|
const filtered = this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
|
|
171
|
-
|
|
195
|
+
const last = filtered[filtered.length - 1];
|
|
196
|
+
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
197
|
+
return last ? this.cloneRecord(last) : undefined;
|
|
172
198
|
}
|
|
173
|
-
|
|
199
|
+
const last = this.requestHistory[this.requestHistory.length - 1];
|
|
200
|
+
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
201
|
+
return last ? this.cloneRecord(last) : undefined;
|
|
174
202
|
}
|
|
175
203
|
// ===== Introspection =====
|
|
176
204
|
getRoutes() {
|
|
@@ -215,10 +243,10 @@ export class CallableMockInstance {
|
|
|
215
243
|
this.plugins = [];
|
|
216
244
|
this.requestHistory = [];
|
|
217
245
|
this.listeners.clear();
|
|
246
|
+
// FIX 3.3: assign a fresh object instead of deleting keys off the caller's
|
|
247
|
+
// reference — this avoids mutating the external state object passed by the user
|
|
218
248
|
if (this.globalConfig.state) {
|
|
219
|
-
|
|
220
|
-
delete this.globalConfig.state[key];
|
|
221
|
-
}
|
|
249
|
+
this.globalConfig.state = {};
|
|
222
250
|
}
|
|
223
251
|
this.logger.log("lifecycle", "Mock fully reset");
|
|
224
252
|
}
|
|
@@ -227,10 +255,10 @@ export class CallableMockInstance {
|
|
|
227
255
|
this.logger.log("lifecycle", "Request history cleared");
|
|
228
256
|
}
|
|
229
257
|
resetState() {
|
|
258
|
+
// FIX 3.3: assign a fresh object instead of deleting keys off the caller's
|
|
259
|
+
// reference — this avoids mutating the external state object passed by the user
|
|
230
260
|
if (this.globalConfig.state) {
|
|
231
|
-
|
|
232
|
-
delete this.globalConfig.state[key];
|
|
233
|
-
}
|
|
261
|
+
this.globalConfig.state = {};
|
|
234
262
|
}
|
|
235
263
|
this.logger.log("lifecycle", "State cleared");
|
|
236
264
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin-pipeline.d.ts","sourceRoot":"","sources":["../src/plugin-pipeline.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"plugin-pipeline.d.ts","sourceRoot":"","sources":["../src/plugin-pipeline.ts"],"names":[],"mappings":"AAGA,uEAAuE;AACvE,UAAU,cAAc;IACtB,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC9D;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,EACzB,OAAO,EAAE,OAAO,CAAC,aAAa,EAC9B,eAAe,EAAE,OAAO,EACxB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAoFjE"}
|
package/dist/plugin-pipeline.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStatusTuple } from "./constants.js";
|
|
1
2
|
import { errorMessage, PluginError } from "./errors.js";
|
|
2
3
|
/**
|
|
3
4
|
* Run all registered plugins in sequence
|
|
@@ -41,9 +42,11 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
|
|
|
41
42
|
throw new PluginError(plugin.name, errorResult);
|
|
42
43
|
}
|
|
43
44
|
// ResponseResult return → recover, stop pipeline
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
45
|
+
// Accept both object-with-status and status tuple [code, body] / [code, body, headers]
|
|
46
|
+
if (isStatusTuple(errorResult) ||
|
|
47
|
+
(typeof errorResult === "object" &&
|
|
48
|
+
errorResult !== null &&
|
|
49
|
+
"status" in errorResult)) {
|
|
47
50
|
response = errorResult;
|
|
48
51
|
break;
|
|
49
52
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"response-parser.d.ts","sourceRoot":"","sources":["../src/response-parser.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"response-parser.d.ts","sourceRoot":"","sources":["../src/response-parser.ts"],"names":[],"mappings":"AAgBA;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,WAAW,EAAE,OAAO,CAAC,WAAW,GAC/B,OAAO,CAAC,QAAQ,CAmDlB"}
|
package/dist/response-parser.js
CHANGED
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.2.
|
|
4
|
+
"version": "2.2.2",
|
|
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": "^
|
|
35
|
+
"@amiceli/vitest-cucumber": "^7.0.0",
|
|
36
36
|
"@types/json-schema": "^7.0.15",
|
|
37
|
-
"@types/node": "^
|
|
38
|
-
"@vitest/ui": "^4.1.
|
|
39
|
-
"vitest": "^4.1.
|
|
37
|
+
"@types/node": "^26.1.0",
|
|
38
|
+
"@vitest/ui": "^4.1.9",
|
|
39
|
+
"vitest": "^4.1.9"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { schmock } from "./index";
|
|
3
|
+
|
|
4
|
+
// ── FIX 1.2: defineRoute must shallow-clone the config ────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("FIX 1.2 — defineRoute must not mutate the caller's config", () => {
|
|
7
|
+
it("does not add contentType to the caller's config object", () => {
|
|
8
|
+
const sharedConfig: Schmock.RouteConfig = {};
|
|
9
|
+
const mock = schmock();
|
|
10
|
+
mock("GET /a", { x: 1 }, sharedConfig);
|
|
11
|
+
// sharedConfig must remain empty — defineRoute must work on a clone
|
|
12
|
+
expect(Object.keys(sharedConfig)).toHaveLength(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("second route registered with same config is not polluted by first route detection", () => {
|
|
16
|
+
const sharedConfig: Schmock.RouteConfig = {};
|
|
17
|
+
const mock = schmock();
|
|
18
|
+
mock("GET /first", { x: 1 }, sharedConfig);
|
|
19
|
+
mock("GET /second", "hello", sharedConfig);
|
|
20
|
+
// sharedConfig must still be clean — neither call should have written to it
|
|
21
|
+
expect(Object.keys(sharedConfig)).toHaveLength(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("mutating caller's config after registration does not change the registered route response", async () => {
|
|
25
|
+
const config: Schmock.RouteConfig = {};
|
|
26
|
+
const mock = schmock();
|
|
27
|
+
mock("GET /item", { value: 99 }, config);
|
|
28
|
+
|
|
29
|
+
// After registration, mutate the caller's config
|
|
30
|
+
config.status = 503;
|
|
31
|
+
config.contentType = "text/html";
|
|
32
|
+
|
|
33
|
+
const response = await mock.handle("GET", "/item");
|
|
34
|
+
// The registered route must use its own cloned config — not 503
|
|
35
|
+
expect(response.status).toBe(200);
|
|
36
|
+
const ct =
|
|
37
|
+
response.headers?.["content-type"] ?? response.headers?.["Content-Type"];
|
|
38
|
+
expect(ct).toContain("application/json");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("each route keeps its own detected contentType even when sharing the same config object", async () => {
|
|
42
|
+
const sharedConfig: Schmock.RouteConfig = {};
|
|
43
|
+
const mock = schmock();
|
|
44
|
+
// First route: object → application/json
|
|
45
|
+
mock("GET /obj", { value: 1 }, sharedConfig);
|
|
46
|
+
// Second route: primitive string → text/plain
|
|
47
|
+
mock("GET /str", "hello", sharedConfig);
|
|
48
|
+
|
|
49
|
+
const objResp = await mock.handle("GET", "/obj");
|
|
50
|
+
const strResp = await mock.handle("GET", "/str");
|
|
51
|
+
|
|
52
|
+
const ctObj =
|
|
53
|
+
objResp.headers?.["content-type"] ?? objResp.headers?.["Content-Type"];
|
|
54
|
+
const ctStr =
|
|
55
|
+
strResp.headers?.["content-type"] ?? strResp.headers?.["Content-Type"];
|
|
56
|
+
|
|
57
|
+
expect(ctObj).toContain("application/json");
|
|
58
|
+
expect(ctStr).toContain("text/plain");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── FIX 2.2: duplicate route detection must normalize trailing slash ──────────
|
|
63
|
+
|
|
64
|
+
describe("FIX 2.2 — duplicate-route detection uses normalized paths", () => {
|
|
65
|
+
it("GET /users/ is treated as duplicate of GET /users", () => {
|
|
66
|
+
const mock = schmock();
|
|
67
|
+
mock("GET /users", [{ id: 1 }], {});
|
|
68
|
+
// Registering with trailing slash must not add a second route
|
|
69
|
+
mock("GET /users/", [{ id: 2 }], {});
|
|
70
|
+
|
|
71
|
+
const routes = mock.getRoutes();
|
|
72
|
+
const userRoutes = routes.filter(
|
|
73
|
+
(r) =>
|
|
74
|
+
r.method === "GET" && (r.path === "/users" || r.path === "/users/"),
|
|
75
|
+
);
|
|
76
|
+
expect(userRoutes).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("first registration wins — trailing-slash variant does not overwrite", async () => {
|
|
80
|
+
const mock = schmock();
|
|
81
|
+
mock("GET /items", [{ id: 1 }], {});
|
|
82
|
+
mock("GET /items/", [{ id: 999 }], {});
|
|
83
|
+
|
|
84
|
+
// Should still get the first registration's data
|
|
85
|
+
const resp = await mock.handle("GET", "/items");
|
|
86
|
+
expect(resp.status).toBe(200);
|
|
87
|
+
expect(Array.isArray(resp.body)).toBe(true);
|
|
88
|
+
expect((resp.body as { id: number }[])[0].id).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("registering verbatim duplicate also produces only one route", () => {
|
|
92
|
+
const mock = schmock();
|
|
93
|
+
mock("GET /same", { ok: true }, {});
|
|
94
|
+
mock("GET /same", { ok: false }, {});
|
|
95
|
+
|
|
96
|
+
const routes = mock.getRoutes();
|
|
97
|
+
const sameRoutes = routes.filter(
|
|
98
|
+
(r) => r.method === "GET" && r.path === "/same",
|
|
99
|
+
);
|
|
100
|
+
expect(sameRoutes).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── FIX 3.3: reset()/resetState() must reassign, not delete keys ─────────────
|
|
105
|
+
|
|
106
|
+
describe("FIX 3.3 — reset()/resetState() must not mutate caller's state", () => {
|
|
107
|
+
it("reset() leaves the external state object untouched", () => {
|
|
108
|
+
const st = { a: 1 };
|
|
109
|
+
const mock = schmock({ state: st });
|
|
110
|
+
mock.reset();
|
|
111
|
+
expect(st).toEqual({ a: 1 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("resetState() leaves the external state object untouched", () => {
|
|
115
|
+
const st = { b: 2 };
|
|
116
|
+
const mock = schmock({ state: st });
|
|
117
|
+
mock.resetState();
|
|
118
|
+
expect(st).toEqual({ b: 2 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("mock.getState() is empty after reset()", () => {
|
|
122
|
+
const st = { x: 10 };
|
|
123
|
+
const mock = schmock({ state: st });
|
|
124
|
+
mock.reset();
|
|
125
|
+
expect(mock.getState()).toEqual({});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("mock.getState() is empty after resetState()", () => {
|
|
129
|
+
const st = { y: 20 };
|
|
130
|
+
const mock = schmock({ state: st });
|
|
131
|
+
mock.resetState();
|
|
132
|
+
expect(mock.getState()).toEqual({});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("generator sees empty state after resetState", async () => {
|
|
136
|
+
const st: Record<string, unknown> = { counter: 0 };
|
|
137
|
+
const mock = schmock({ state: st });
|
|
138
|
+
mock("GET /stateful", (ctx: Schmock.RequestContext) => {
|
|
139
|
+
return { stateKeys: Object.keys(ctx.state) };
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
mock.resetState();
|
|
143
|
+
|
|
144
|
+
const resp = await mock.handle("GET", "/stateful");
|
|
145
|
+
expect(resp.status).toBe(200);
|
|
146
|
+
// After resetState, the generator should see an empty state
|
|
147
|
+
expect((resp.body as { stateKeys: string[] }).stateKeys).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── FIX 2.3: history()/lastRequest() must return deep clones ─────────────────
|
|
152
|
+
|
|
153
|
+
describe("FIX 2.3 — history() and lastRequest() return deep clones", () => {
|
|
154
|
+
it("mutating a history() record's nested body does not corrupt internal history", async () => {
|
|
155
|
+
const mock = schmock();
|
|
156
|
+
mock("GET /nested", { deep: { value: "original" } }, {});
|
|
157
|
+
|
|
158
|
+
await mock.handle("GET", "/nested");
|
|
159
|
+
|
|
160
|
+
// First read: mutate the returned record's body
|
|
161
|
+
const records1 = mock.history();
|
|
162
|
+
const firstRecord = records1[0];
|
|
163
|
+
(firstRecord.response.body as { deep: { value: string } }).deep.value =
|
|
164
|
+
"MUTATED";
|
|
165
|
+
|
|
166
|
+
// Second read: internal history must be unchanged
|
|
167
|
+
const records2 = mock.history();
|
|
168
|
+
expect(
|
|
169
|
+
(records2[0].response.body as { deep: { value: string } }).deep.value,
|
|
170
|
+
).toBe("original");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("mutating filtered history() record does not corrupt internal history", async () => {
|
|
174
|
+
const mock = schmock();
|
|
175
|
+
mock("GET /data", { info: { count: 42 } }, {});
|
|
176
|
+
await mock.handle("GET", "/data");
|
|
177
|
+
|
|
178
|
+
const filtered = mock.history("GET", "/data");
|
|
179
|
+
(filtered[0].response.body as { info: { count: number } }).info.count = 0;
|
|
180
|
+
|
|
181
|
+
const again = mock.history("GET", "/data");
|
|
182
|
+
expect(
|
|
183
|
+
(again[0].response.body as { info: { count: number } }).info.count,
|
|
184
|
+
).toBe(42);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("mutating lastRequest() record does not corrupt internal history", async () => {
|
|
188
|
+
const mock = schmock();
|
|
189
|
+
mock("GET /last", { meta: { tag: "safe" } }, {});
|
|
190
|
+
await mock.handle("GET", "/last");
|
|
191
|
+
|
|
192
|
+
const last = mock.lastRequest();
|
|
193
|
+
if (!last) throw new Error("expected a record");
|
|
194
|
+
(last.response.body as { meta: { tag: string } }).meta.tag = "corrupted";
|
|
195
|
+
|
|
196
|
+
const again = mock.lastRequest();
|
|
197
|
+
if (!again) throw new Error("expected a record");
|
|
198
|
+
expect((again.response.body as { meta: { tag: string } }).meta.tag).toBe(
|
|
199
|
+
"safe",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("mutating filtered lastRequest() record does not corrupt internal history", async () => {
|
|
204
|
+
const mock = schmock();
|
|
205
|
+
mock("POST /echo", { msg: { text: "hello" } }, {});
|
|
206
|
+
await mock.handle("POST", "/echo");
|
|
207
|
+
|
|
208
|
+
const last = mock.lastRequest("POST", "/echo");
|
|
209
|
+
if (!last) throw new Error("expected a record");
|
|
210
|
+
(last.response.body as { msg: { text: string } }).msg.text = "CHANGED";
|
|
211
|
+
|
|
212
|
+
const again = mock.lastRequest("POST", "/echo");
|
|
213
|
+
if (!again) throw new Error("expected a record");
|
|
214
|
+
expect((again.response.body as { msg: { text: string } }).msg.text).toBe(
|
|
215
|
+
"hello",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { schmock } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("isResponseObject guard — status type check", () => {
|
|
5
|
+
describe("non-numeric status must NOT be treated as response envelope", () => {
|
|
6
|
+
it("returns status 200 (default) when generator returns { status: 'ok', body: 'hello' }", async () => {
|
|
7
|
+
const mock = schmock();
|
|
8
|
+
mock("GET /bad-envelope", () => ({ status: "ok", body: "hello" }));
|
|
9
|
+
|
|
10
|
+
const response = await mock.handle("GET", "/bad-envelope");
|
|
11
|
+
|
|
12
|
+
// The string status must never leak into response.status
|
|
13
|
+
expect(response.status).toBe(200);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns the whole object as body when generator returns { status: 'ok', body: 'hello' }", async () => {
|
|
17
|
+
const mock = schmock();
|
|
18
|
+
mock("GET /bad-envelope-body", () => ({ status: "ok", body: "hello" }));
|
|
19
|
+
|
|
20
|
+
const response = await mock.handle("GET", "/bad-envelope-body");
|
|
21
|
+
|
|
22
|
+
// The original object must be treated as plain body, not unwrapped
|
|
23
|
+
expect(response.body).toEqual({ status: "ok", body: "hello" });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("numeric status IS correctly treated as response envelope", () => {
|
|
28
|
+
it("returns status 201 and unwrapped body when generator returns { status: 201, body: { id: 1 } }", async () => {
|
|
29
|
+
const mock = schmock();
|
|
30
|
+
mock("POST /create", () => ({ status: 201, body: { id: 1 } }));
|
|
31
|
+
|
|
32
|
+
const response = await mock.handle("POST", "/create");
|
|
33
|
+
|
|
34
|
+
expect(response.status).toBe(201);
|
|
35
|
+
expect(response.body).toEqual({ id: 1 });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/builder.ts
CHANGED
|
@@ -89,31 +89,34 @@ export class CallableMockInstance {
|
|
|
89
89
|
generator: Schmock.Generator,
|
|
90
90
|
config: Schmock.RouteConfig,
|
|
91
91
|
): this {
|
|
92
|
+
// FIX 1.2: shallow-clone the caller's config so mutations below stay private
|
|
93
|
+
const routeConfig = { ...config };
|
|
94
|
+
|
|
92
95
|
// Auto-detect contentType if not provided
|
|
93
|
-
if (!
|
|
96
|
+
if (!routeConfig.contentType) {
|
|
94
97
|
if (typeof generator === "function") {
|
|
95
98
|
// Default to JSON for function generators
|
|
96
|
-
|
|
99
|
+
routeConfig.contentType = "application/json";
|
|
97
100
|
} else if (
|
|
98
101
|
typeof generator === "string" ||
|
|
99
102
|
typeof generator === "number" ||
|
|
100
103
|
typeof generator === "boolean"
|
|
101
104
|
) {
|
|
102
105
|
// Default to plain text for primitives
|
|
103
|
-
|
|
106
|
+
routeConfig.contentType = "text/plain";
|
|
104
107
|
} else if (Buffer.isBuffer(generator)) {
|
|
105
108
|
// Default to octet-stream for buffers
|
|
106
|
-
|
|
109
|
+
routeConfig.contentType = "application/octet-stream";
|
|
107
110
|
} else {
|
|
108
111
|
// Default to JSON for objects/arrays
|
|
109
|
-
|
|
112
|
+
routeConfig.contentType = "application/json";
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
// Validate generator matches contentType if it's static data
|
|
114
117
|
if (
|
|
115
118
|
typeof generator !== "function" &&
|
|
116
|
-
|
|
119
|
+
routeConfig.contentType === "application/json"
|
|
117
120
|
) {
|
|
118
121
|
try {
|
|
119
122
|
JSON.stringify(generator);
|
|
@@ -128,9 +131,13 @@ export class CallableMockInstance {
|
|
|
128
131
|
// Parse the route key to create pattern and extract parameters
|
|
129
132
|
const parsed = parseRouteKey(route);
|
|
130
133
|
|
|
131
|
-
//
|
|
134
|
+
// FIX 2.2: normalize paths before duplicate check so /users and /users/ are
|
|
135
|
+
// treated as the same route (consistent with the static-route Map key below)
|
|
136
|
+
const normalizedParsedPath = normalizePath(parsed.path);
|
|
132
137
|
const existing = this.routes.find(
|
|
133
|
-
(r) =>
|
|
138
|
+
(r) =>
|
|
139
|
+
r.method === parsed.method &&
|
|
140
|
+
normalizePath(r.path) === normalizedParsedPath,
|
|
134
141
|
);
|
|
135
142
|
if (existing) {
|
|
136
143
|
this.logger.log(
|
|
@@ -147,7 +154,7 @@ export class CallableMockInstance {
|
|
|
147
154
|
method: parsed.method,
|
|
148
155
|
path: parsed.path,
|
|
149
156
|
generator,
|
|
150
|
-
config,
|
|
157
|
+
config: routeConfig,
|
|
151
158
|
};
|
|
152
159
|
|
|
153
160
|
this.routes.push(compiledRoute);
|
|
@@ -159,7 +166,7 @@ export class CallableMockInstance {
|
|
|
159
166
|
}
|
|
160
167
|
|
|
161
168
|
this.logger.log("route", `Route defined: ${route}`, {
|
|
162
|
-
contentType:
|
|
169
|
+
contentType: routeConfig.contentType,
|
|
163
170
|
generatorType: typeof generator,
|
|
164
171
|
hasParams: parsed.params.length > 0,
|
|
165
172
|
});
|
|
@@ -191,13 +198,32 @@ export class CallableMockInstance {
|
|
|
191
198
|
|
|
192
199
|
// ===== Request Spy / History API =====
|
|
193
200
|
|
|
201
|
+
/**
|
|
202
|
+
* FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
|
|
203
|
+
* history by mutating nested body/response objects.
|
|
204
|
+
* Falls back to a shallow spread for bodies that are not structuredClone-able
|
|
205
|
+
* (e.g. Buffers, streams — rare in practice but defensively handled).
|
|
206
|
+
*/
|
|
207
|
+
private cloneRecord(r: Schmock.RequestRecord): Schmock.RequestRecord {
|
|
208
|
+
try {
|
|
209
|
+
return structuredClone(r);
|
|
210
|
+
} catch {
|
|
211
|
+
return {
|
|
212
|
+
...r,
|
|
213
|
+
response: { ...r.response },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
194
218
|
history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
|
|
195
219
|
if (method || path) {
|
|
196
|
-
return this.requestHistory
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
return this.requestHistory
|
|
221
|
+
.filter(
|
|
222
|
+
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
223
|
+
)
|
|
224
|
+
.map((r) => this.cloneRecord(r));
|
|
199
225
|
}
|
|
200
|
-
return
|
|
226
|
+
return this.requestHistory.map((r) => this.cloneRecord(r));
|
|
201
227
|
}
|
|
202
228
|
|
|
203
229
|
called(method?: Schmock.HttpMethod, path?: string): boolean {
|
|
@@ -226,9 +252,13 @@ export class CallableMockInstance {
|
|
|
226
252
|
const filtered = this.requestHistory.filter(
|
|
227
253
|
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
228
254
|
);
|
|
229
|
-
|
|
255
|
+
const last = filtered[filtered.length - 1];
|
|
256
|
+
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
257
|
+
return last ? this.cloneRecord(last) : undefined;
|
|
230
258
|
}
|
|
231
|
-
|
|
259
|
+
const last = this.requestHistory[this.requestHistory.length - 1];
|
|
260
|
+
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
261
|
+
return last ? this.cloneRecord(last) : undefined;
|
|
232
262
|
}
|
|
233
263
|
|
|
234
264
|
// ===== Introspection =====
|
|
@@ -288,10 +318,10 @@ export class CallableMockInstance {
|
|
|
288
318
|
this.plugins = [];
|
|
289
319
|
this.requestHistory = [];
|
|
290
320
|
this.listeners.clear();
|
|
321
|
+
// FIX 3.3: assign a fresh object instead of deleting keys off the caller's
|
|
322
|
+
// reference — this avoids mutating the external state object passed by the user
|
|
291
323
|
if (this.globalConfig.state) {
|
|
292
|
-
|
|
293
|
-
delete this.globalConfig.state[key];
|
|
294
|
-
}
|
|
324
|
+
this.globalConfig.state = {};
|
|
295
325
|
}
|
|
296
326
|
this.logger.log("lifecycle", "Mock fully reset");
|
|
297
327
|
}
|
|
@@ -302,10 +332,10 @@ export class CallableMockInstance {
|
|
|
302
332
|
}
|
|
303
333
|
|
|
304
334
|
resetState(): void {
|
|
335
|
+
// FIX 3.3: assign a fresh object instead of deleting keys off the caller's
|
|
336
|
+
// reference — this avoids mutating the external state object passed by the user
|
|
305
337
|
if (this.globalConfig.state) {
|
|
306
|
-
|
|
307
|
-
delete this.globalConfig.state[key];
|
|
308
|
-
}
|
|
338
|
+
this.globalConfig.state = {};
|
|
309
339
|
}
|
|
310
340
|
this.logger.log("lifecycle", "State cleared");
|
|
311
341
|
}
|
package/src/plugin-pipeline.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStatusTuple } from "./constants.js";
|
|
1
2
|
import { errorMessage, PluginError } from "./errors.js";
|
|
2
3
|
|
|
3
4
|
/** Structural typing — DebugLogger satisfies this without an import */
|
|
@@ -68,10 +69,12 @@ export async function runPluginPipeline(
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// ResponseResult return → recover, stop pipeline
|
|
72
|
+
// Accept both object-with-status and status tuple [code, body] / [code, body, headers]
|
|
71
73
|
if (
|
|
72
|
-
|
|
73
|
-
errorResult
|
|
74
|
-
|
|
74
|
+
isStatusTuple(errorResult) ||
|
|
75
|
+
(typeof errorResult === "object" &&
|
|
76
|
+
errorResult !== null &&
|
|
77
|
+
"status" in errorResult)
|
|
75
78
|
) {
|
|
76
79
|
response = errorResult;
|
|
77
80
|
break;
|
package/src/response-parser.ts
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
2
|
+
import { expect } from "vitest";
|
|
3
|
+
import { schmock } from "../index";
|
|
4
|
+
|
|
5
|
+
const feature = await loadFeature("../../features/audit-core-builder.feature");
|
|
6
|
+
|
|
7
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
8
|
+
// ── FIX 1.2: defineRoute must shallow-clone the config ───────────────────
|
|
9
|
+
|
|
10
|
+
Scenario(
|
|
11
|
+
"defineRoute does not mutate the caller's config object",
|
|
12
|
+
({ Given, When, Then }) => {
|
|
13
|
+
let sharedConfig: Schmock.RouteConfig;
|
|
14
|
+
let mock: Schmock.CallableMockInstance;
|
|
15
|
+
|
|
16
|
+
Given("a shared config object with no contentType", () => {
|
|
17
|
+
sharedConfig = {};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
When("I register two routes with the same config object", () => {
|
|
21
|
+
mock = schmock();
|
|
22
|
+
mock("GET /route1", { name: "route1" }, sharedConfig);
|
|
23
|
+
mock("GET /route2", { name: "route2" }, sharedConfig);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
Then(
|
|
27
|
+
"the shared config object remains unchanged after registration",
|
|
28
|
+
() => {
|
|
29
|
+
expect(Object.keys(sharedConfig)).toHaveLength(0);
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
Scenario(
|
|
36
|
+
"Mutating a returned config does not affect the registered route",
|
|
37
|
+
({ Given, When, Then, And }) => {
|
|
38
|
+
let singleConfig: Schmock.RouteConfig;
|
|
39
|
+
let mock: Schmock.CallableMockInstance;
|
|
40
|
+
|
|
41
|
+
Given("a single config object with no contentType", () => {
|
|
42
|
+
singleConfig = {};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
When("I register one route with that config object", () => {
|
|
46
|
+
mock = schmock();
|
|
47
|
+
mock("GET /item", { value: 99 }, singleConfig);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
And("I mutate the config object after registration", () => {
|
|
51
|
+
singleConfig.status = 503;
|
|
52
|
+
singleConfig.contentType = "text/html";
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
Then(
|
|
56
|
+
"the registered route still responds with application/json content type",
|
|
57
|
+
async () => {
|
|
58
|
+
const response = await mock.handle("GET", "/item");
|
|
59
|
+
expect(response.status).toBe(200);
|
|
60
|
+
const ct =
|
|
61
|
+
response.headers?.["content-type"] ??
|
|
62
|
+
response.headers?.["Content-Type"];
|
|
63
|
+
expect(ct).toContain("application/json");
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// ── FIX 2.2: duplicate route detection must normalize trailing slash ──────
|
|
70
|
+
|
|
71
|
+
Scenario(
|
|
72
|
+
"Trailing-slash path is treated as duplicate",
|
|
73
|
+
({ Given, And, Then }) => {
|
|
74
|
+
let mock: Schmock.CallableMockInstance;
|
|
75
|
+
|
|
76
|
+
Given("a fresh mock instance", () => {
|
|
77
|
+
mock = schmock();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
And("I register GET /users without trailing slash", () => {
|
|
81
|
+
mock("GET /users", [{ id: 1 }], {});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
And("I register GET /users with trailing slash", () => {
|
|
85
|
+
mock("GET /users/", [{ id: 2 }], {});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
Then("only one route exists for GET /users", () => {
|
|
89
|
+
const routes = mock.getRoutes();
|
|
90
|
+
const userRoutes = routes.filter(
|
|
91
|
+
(r) =>
|
|
92
|
+
r.method === "GET" && (r.path === "/users" || r.path === "/users/"),
|
|
93
|
+
);
|
|
94
|
+
expect(userRoutes).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// ── FIX 3.3: reset()/resetState() must not mutate caller's state ─────────
|
|
100
|
+
|
|
101
|
+
Scenario(
|
|
102
|
+
"reset() does not mutate the caller's state object",
|
|
103
|
+
({ Given, When, Then, And }) => {
|
|
104
|
+
let externalState: Record<string, unknown>;
|
|
105
|
+
let mock: Schmock.CallableMockInstance;
|
|
106
|
+
|
|
107
|
+
Given('a mock with external state containing key "a" equal to 1', () => {
|
|
108
|
+
externalState = { a: 1 };
|
|
109
|
+
mock = schmock({ state: externalState });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
When("I call mock reset", () => {
|
|
113
|
+
mock.reset();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
Then('the external state still has key "a" equal to 1', () => {
|
|
117
|
+
expect(externalState).toEqual({ a: 1 });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
And("the mock internal state is empty", () => {
|
|
121
|
+
expect(mock.getState()).toEqual({});
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
Scenario(
|
|
127
|
+
"resetState() does not mutate the caller's state object",
|
|
128
|
+
({ Given, When, Then, And }) => {
|
|
129
|
+
let externalState: Record<string, unknown>;
|
|
130
|
+
let mock: Schmock.CallableMockInstance;
|
|
131
|
+
|
|
132
|
+
Given('a mock with external state containing key "b" equal to 2', () => {
|
|
133
|
+
externalState = { b: 2 };
|
|
134
|
+
mock = schmock({ state: externalState });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
When("I call mock resetState", () => {
|
|
138
|
+
mock.resetState();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
Then('the external state still has key "b" equal to 2', () => {
|
|
142
|
+
expect(externalState).toEqual({ b: 2 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
And("the mock internal state is empty after resetState", () => {
|
|
146
|
+
expect(mock.getState()).toEqual({});
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// ── FIX 2.3: history()/lastRequest() must return deep clones ─────────────
|
|
152
|
+
|
|
153
|
+
Scenario(
|
|
154
|
+
"history() returns deep clones of request records",
|
|
155
|
+
({ Given, When, Then, And }) => {
|
|
156
|
+
let mock: Schmock.CallableMockInstance;
|
|
157
|
+
|
|
158
|
+
Given("a fresh mock with a route returning a nested body", () => {
|
|
159
|
+
mock = schmock();
|
|
160
|
+
mock("GET /data", { nested: { value: "original" } }, {});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
When("I handle that route once", async () => {
|
|
164
|
+
await mock.handle("GET", "/data");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
And("I mutate the response body of the first history record", () => {
|
|
168
|
+
const records = mock.history();
|
|
169
|
+
expect(records).toHaveLength(1);
|
|
170
|
+
const body = records[0].response.body as { nested: { value: string } };
|
|
171
|
+
body.nested.value = "MUTATED";
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
Then("history returns the original body unchanged", () => {
|
|
175
|
+
const records = mock.history();
|
|
176
|
+
const body = records[0].response.body as { nested: { value: string } };
|
|
177
|
+
expect(body.nested.value).toBe("original");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
And("lastRequest returns the original body unchanged", () => {
|
|
181
|
+
const last = mock.lastRequest();
|
|
182
|
+
if (!last) throw new Error("expected a record");
|
|
183
|
+
const body = last.response.body as { nested: { value: string } };
|
|
184
|
+
expect(body.nested.value).toBe("original");
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
2
|
+
import { expect } from "vitest";
|
|
3
|
+
import { schmock } from "../index";
|
|
4
|
+
|
|
5
|
+
const feature = await loadFeature("../../features/audit-onerror-tuple.feature");
|
|
6
|
+
|
|
7
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
8
|
+
let mock: Schmock.CallableMockInstance;
|
|
9
|
+
let response: Awaited<ReturnType<Schmock.CallableMockInstance["handle"]>>;
|
|
10
|
+
let thrownError: unknown;
|
|
11
|
+
|
|
12
|
+
Scenario(
|
|
13
|
+
"2-element tuple from onError recovers with correct status and body",
|
|
14
|
+
({ Given, When, Then, And }) => {
|
|
15
|
+
Given(
|
|
16
|
+
"a mock with a plugin whose process throws and onError returns a 2-element tuple",
|
|
17
|
+
() => {
|
|
18
|
+
mock = schmock({ state: {} });
|
|
19
|
+
mock("GET /fail", null, {}).pipe({
|
|
20
|
+
name: "failing-plugin",
|
|
21
|
+
process: (_ctx, _response) => {
|
|
22
|
+
throw new Error("simulated failure");
|
|
23
|
+
},
|
|
24
|
+
onError: (_error, _ctx) => {
|
|
25
|
+
return [503, { error: "recovered" }] as [number, unknown];
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
When('I handle a request to "GET /fail"', async () => {
|
|
32
|
+
thrownError = undefined;
|
|
33
|
+
try {
|
|
34
|
+
response = await mock.handle("GET", "/fail");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
thrownError = err;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
Then("the response status is 503", () => {
|
|
41
|
+
expect(response.status).toBe(503);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
And('the response body is \'{"error":"recovered"}\'', () => {
|
|
45
|
+
expect(response.body).toEqual({ error: "recovered" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
And("no error is thrown", () => {
|
|
49
|
+
expect(thrownError).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
Scenario(
|
|
55
|
+
"3-element tuple from onError recovers and includes custom headers",
|
|
56
|
+
({ Given, When, Then, And }) => {
|
|
57
|
+
Given(
|
|
58
|
+
"a mock with a plugin whose process throws and onError returns a 3-element tuple with headers",
|
|
59
|
+
() => {
|
|
60
|
+
mock = schmock({ state: {} });
|
|
61
|
+
mock("GET /fail-with-headers", null, {}).pipe({
|
|
62
|
+
name: "failing-plugin-headers",
|
|
63
|
+
process: (_ctx, _response) => {
|
|
64
|
+
throw new Error("simulated failure with headers");
|
|
65
|
+
},
|
|
66
|
+
onError: (_error, _ctx) => {
|
|
67
|
+
return [
|
|
68
|
+
503,
|
|
69
|
+
{ error: "recovered with headers" },
|
|
70
|
+
{ "x-recovery": "true" },
|
|
71
|
+
] as [number, unknown, Record<string, string>];
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
When('I handle a request to "GET /fail-with-headers"', async () => {
|
|
78
|
+
thrownError = undefined;
|
|
79
|
+
try {
|
|
80
|
+
response = await mock.handle("GET", "/fail-with-headers");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
thrownError = err;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
Then("the response status is 503", () => {
|
|
87
|
+
expect(response.status).toBe(503);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
And('the response body is \'{"error":"recovered with headers"}\'', () => {
|
|
91
|
+
expect(response.body).toEqual({ error: "recovered with headers" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
And("no error is thrown", () => {
|
|
95
|
+
expect(thrownError).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
});
|
|
@@ -64,6 +64,9 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
// Generous ceiling, not a perf target: in-process handlers are sub-millisecond,
|
|
68
|
+
// so this only guards against a pathological hang. A tight bound (e.g. 50ms)
|
|
69
|
+
// flakes under CI load where 100 concurrent requests contend for the event loop.
|
|
67
70
|
And("the average response time should be under {int}ms", (_, maxTime: number) => {
|
|
68
71
|
const avgTime = responsesTimes.reduce((a, b) => a + b, 0) / responsesTimes.length;
|
|
69
72
|
expect(avgTime).toBeLessThan(maxTime);
|
|
@@ -28,8 +28,11 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
28
28
|
elapsed = performance.now() - start;
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// Upper bound is intentionally generous: it only needs to prove the 10ms
|
|
32
|
+
// route delay was used instead of the 200ms global. A tight bound here
|
|
33
|
+
// flakes under CI load (a single timer can be delayed tens of ms).
|
|
34
|
+
Then("the response took less than 150ms", () => {
|
|
35
|
+
expect(elapsed).toBeLessThan(150);
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
When('I request "GET /default" with timing', async () => {
|