@schmock/core 2.2.3 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/abort.d.ts +3 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/abort.js +30 -0
- package/dist/binary.d.ts +8 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +9 -0
- package/dist/builder.d.ts +31 -10
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +804 -203
- package/dist/constants.d.ts +38 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +123 -2
- package/dist/errors.d.ts +14 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +41 -6
- package/dist/helpers.d.ts +9 -0
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +18 -2
- package/dist/http-helpers.d.ts +52 -5
- package/dist/http-helpers.d.ts.map +1 -1
- package/dist/http-helpers.js +153 -37
- package/dist/index.d.ts +381 -60
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/interceptor.d.ts +11 -1
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +354 -156
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +12 -3
- package/dist/plugin-pipeline.d.ts +11 -4
- package/dist/plugin-pipeline.d.ts.map +1 -1
- package/dist/plugin-pipeline.js +117 -39
- package/dist/response-normalizer.d.ts +16 -0
- package/dist/response-normalizer.d.ts.map +1 -0
- package/dist/response-normalizer.js +316 -0
- package/dist/response-parser.d.ts.map +1 -1
- package/dist/response-parser.js +85 -18
- package/dist/route-matcher.d.ts +3 -0
- package/dist/route-matcher.d.ts.map +1 -1
- package/dist/route-matcher.js +12 -6
- package/dist/types.d.ts +7 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -8
- package/src/audit-core-builder.test.ts +0 -218
- package/src/audit-response-guard.test.ts +0 -38
- package/src/builder.test.ts +0 -289
- package/src/builder.ts +0 -701
- package/src/constants.test.ts +0 -99
- package/src/constants.ts +0 -73
- package/src/debug.test.ts +0 -241
- package/src/delay.test.ts +0 -319
- package/src/dist-shape.test.ts +0 -35
- package/src/errors.test.ts +0 -223
- package/src/errors.ts +0 -130
- package/src/factory.test.ts +0 -133
- package/src/helpers.test.ts +0 -147
- package/src/helpers.ts +0 -58
- package/src/http-helpers.ts +0 -113
- package/src/index.ts +0 -151
- package/src/interceptor.test.ts +0 -291
- package/src/interceptor.ts +0 -320
- package/src/namespace.test.ts +0 -274
- package/src/parser.property.test.ts +0 -594
- package/src/parser.test.ts +0 -148
- package/src/parser.ts +0 -64
- package/src/plugin-pipeline.ts +0 -103
- package/src/plugin-system.test.ts +0 -602
- package/src/response-parser.ts +0 -75
- package/src/response-parsing.test.ts +0 -333
- package/src/route-matcher.ts +0 -69
- package/src/route-matching.test.ts +0 -394
- package/src/server.test.ts +0 -165
- package/src/smart-defaults.test.ts +0 -361
- package/src/steps/async-support.steps.ts +0 -400
- package/src/steps/audit-core-builder.steps.ts +0 -188
- package/src/steps/audit-onerror-tuple.steps.ts +0 -99
- package/src/steps/basic-usage.steps.ts +0 -245
- package/src/steps/developer-experience.steps.ts +0 -204
- package/src/steps/error-handling.steps.ts +0 -345
- package/src/steps/fluent-api.steps.ts +0 -255
- package/src/steps/http-methods.steps.ts +0 -331
- package/src/steps/interceptor.steps.ts +0 -260
- package/src/steps/lifecycle-events.steps.ts +0 -142
- package/src/steps/performance-reliability.steps.ts +0 -423
- package/src/steps/plugin-integration.steps.ts +0 -280
- package/src/steps/request-history.steps.ts +0 -276
- package/src/steps/response-delay.steps.ts +0 -88
- package/src/steps/route-key-format.steps.ts +0 -99
- package/src/steps/standalone-server.steps.ts +0 -233
- package/src/steps/state-concurrency.steps.ts +0 -739
- package/src/steps/stateful-workflows.steps.ts +0 -353
- package/src/types.ts +0 -36
|
@@ -1,739 +0,0 @@
|
|
|
1
|
-
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
2
|
-
import { expect } from "vitest";
|
|
3
|
-
import { schmock } from "../index";
|
|
4
|
-
import type { CallableMockInstance, Plugin } from "../types";
|
|
5
|
-
|
|
6
|
-
// ---------- State shape interfaces for each scenario ----------
|
|
7
|
-
// Index signatures ensure compatibility with Record<string, unknown>
|
|
8
|
-
|
|
9
|
-
interface CounterState { counter: number; [k: string]: unknown }
|
|
10
|
-
interface MultiCounterState { users: number; posts: number; comments: number; [k: string]: unknown }
|
|
11
|
-
interface ValueState { value: number; [k: string]: unknown }
|
|
12
|
-
interface UpdateBody { newValue: number }
|
|
13
|
-
interface PluginState {
|
|
14
|
-
requestCount: number;
|
|
15
|
-
pluginData: Record<string, number>;
|
|
16
|
-
[k: string]: unknown;
|
|
17
|
-
}
|
|
18
|
-
interface SessionState {
|
|
19
|
-
sessions: Record<string, { user: string; loginTime: string }>;
|
|
20
|
-
activeUsers: number;
|
|
21
|
-
[k: string]: unknown;
|
|
22
|
-
}
|
|
23
|
-
interface LoginBody { username: string }
|
|
24
|
-
interface DataItem { id: number; value: number }
|
|
25
|
-
interface DataArrayState { data: DataItem[]; [k: string]: unknown }
|
|
26
|
-
interface DataUpdateBody { value: number }
|
|
27
|
-
interface CacheState {
|
|
28
|
-
cache: Record<string, { value: string; timestamp: number }>;
|
|
29
|
-
cacheSize: number;
|
|
30
|
-
maxCacheSize: number;
|
|
31
|
-
[k: string]: unknown;
|
|
32
|
-
}
|
|
33
|
-
interface CacheBody { value: string }
|
|
34
|
-
interface NestedUsersState {
|
|
35
|
-
users: {
|
|
36
|
-
profiles: Record<string, Record<string, unknown>>;
|
|
37
|
-
preferences: Record<string, Record<string, unknown>>;
|
|
38
|
-
activity: Record<string, Array<Record<string, unknown>>>;
|
|
39
|
-
};
|
|
40
|
-
[k: string]: unknown;
|
|
41
|
-
}
|
|
42
|
-
interface TransactionState {
|
|
43
|
-
transactions: Array<{ amount: number; balanceAfter: number; timestamp: number }>;
|
|
44
|
-
balance: number;
|
|
45
|
-
[k: string]: unknown;
|
|
46
|
-
}
|
|
47
|
-
interface TransactionBody { amount: number }
|
|
48
|
-
|
|
49
|
-
// ---------- Response body interfaces for assertions ----------
|
|
50
|
-
|
|
51
|
-
interface CounterBody { counter: number; previous: number }
|
|
52
|
-
interface TypeCountBody { type: string; count: number }
|
|
53
|
-
interface InstanceBody { instance: number; value?: number; updated?: number }
|
|
54
|
-
interface PluginResponseBody { plugin1Count: number; plugin2Count: number; base: string }
|
|
55
|
-
interface SessionResponseBody {
|
|
56
|
-
sessionId: string;
|
|
57
|
-
message: string;
|
|
58
|
-
activeUsers: number;
|
|
59
|
-
session?: { user: string; loginTime: string };
|
|
60
|
-
totalSessions?: number;
|
|
61
|
-
error?: string;
|
|
62
|
-
}
|
|
63
|
-
interface DataUpdateResponseBody { id: number; updated: number; total: number }
|
|
64
|
-
interface StatsResponseBody { total: number; average: number; items: number }
|
|
65
|
-
interface CacheResponseBody { key: string; cached: boolean; cacheSize: number }
|
|
66
|
-
interface ProfileResponseBody { userId: string; profile?: unknown; preferences?: unknown; activityCount?: number }
|
|
67
|
-
interface TransactionResponseBody { success: boolean; newBalance: number; transactionCount: number; error?: string; balance?: number }
|
|
68
|
-
|
|
69
|
-
const feature = await loadFeature("../../features/state-concurrency.feature");
|
|
70
|
-
|
|
71
|
-
describeFeature(feature, ({ Scenario }) => {
|
|
72
|
-
let mock: CallableMockInstance;
|
|
73
|
-
let mock1: CallableMockInstance;
|
|
74
|
-
let mock2: CallableMockInstance;
|
|
75
|
-
let responses: any[] = [];
|
|
76
|
-
|
|
77
|
-
Scenario("Concurrent state updates with race conditions", ({ Given, When, Then, And }) => {
|
|
78
|
-
Given("I create a mock with a shared counter state", () => {
|
|
79
|
-
mock = schmock({ state: { counter: 0 } });
|
|
80
|
-
mock("POST /increment", ({ state }) => {
|
|
81
|
-
const s = state as CounterState;
|
|
82
|
-
const current = s.counter;
|
|
83
|
-
s.counter = current + 1;
|
|
84
|
-
return { counter: s.counter, previous: current };
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
When("I make 5 concurrent increment requests", async () => {
|
|
89
|
-
const promises = Array.from({ length: 5 }, () =>
|
|
90
|
-
mock.handle("POST", "/increment")
|
|
91
|
-
);
|
|
92
|
-
responses = await Promise.all(promises);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
Then("all requests should complete successfully", () => {
|
|
96
|
-
expect(responses).toHaveLength(5);
|
|
97
|
-
responses.forEach(response => {
|
|
98
|
-
expect(response.status).toBe(200);
|
|
99
|
-
expect(response.body).toHaveProperty("counter");
|
|
100
|
-
expect(response.body).toHaveProperty("previous");
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
And("the final counter should reflect all increments", () => {
|
|
105
|
-
const finalCounters = responses.map(r => (r.body as CounterBody).counter);
|
|
106
|
-
const maxCounter = Math.max(...finalCounters);
|
|
107
|
-
expect(maxCounter).toBe(5);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
And("each response should show sequential progression", () => {
|
|
111
|
-
const previousValues = responses.map(r => (r.body as CounterBody).previous);
|
|
112
|
-
const counterValues = responses.map(r => (r.body as CounterBody).counter);
|
|
113
|
-
|
|
114
|
-
// All previous values should be unique (no duplicates due to race conditions)
|
|
115
|
-
const uniquePrevious = new Set(previousValues);
|
|
116
|
-
expect(uniquePrevious.size).toBe(5);
|
|
117
|
-
|
|
118
|
-
// Counter values should be previous + 1
|
|
119
|
-
responses.forEach(response => {
|
|
120
|
-
const body = response.body as CounterBody;
|
|
121
|
-
expect(body.counter).toBe(body.previous + 1);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
Scenario("Concurrent access to different state properties", ({ Given, When, Then, And }) => {
|
|
127
|
-
Given("I create a mock with separate user, post, and comment counters", () => {
|
|
128
|
-
mock = schmock({
|
|
129
|
-
state: {
|
|
130
|
-
users: 0,
|
|
131
|
-
posts: 0,
|
|
132
|
-
comments: 0,
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
mock("POST /users", ({ state }) => {
|
|
136
|
-
const s = state as MultiCounterState;
|
|
137
|
-
s.users++;
|
|
138
|
-
return { type: "user", count: s.users };
|
|
139
|
-
});
|
|
140
|
-
mock("POST /posts", ({ state }) => {
|
|
141
|
-
const s = state as MultiCounterState;
|
|
142
|
-
s.posts++;
|
|
143
|
-
return { type: "post", count: s.posts };
|
|
144
|
-
});
|
|
145
|
-
mock("POST /comments", ({ state }) => {
|
|
146
|
-
const s = state as MultiCounterState;
|
|
147
|
-
s.comments++;
|
|
148
|
-
return { type: "comment", count: s.comments };
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
When("I make concurrent requests to different endpoints", async () => {
|
|
153
|
-
const promises = [
|
|
154
|
-
mock.handle("POST", "/users"),
|
|
155
|
-
mock.handle("POST", "/users"),
|
|
156
|
-
mock.handle("POST", "/posts"),
|
|
157
|
-
mock.handle("POST", "/posts"),
|
|
158
|
-
mock.handle("POST", "/comments"),
|
|
159
|
-
mock.handle("POST", "/comments"),
|
|
160
|
-
mock.handle("POST", "/users"),
|
|
161
|
-
];
|
|
162
|
-
responses = await Promise.all(promises);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
Then("each endpoint should maintain its own counter correctly", () => {
|
|
166
|
-
const userResponses = responses.filter(r => (r.body as TypeCountBody).type === "user");
|
|
167
|
-
const postResponses = responses.filter(r => (r.body as TypeCountBody).type === "post");
|
|
168
|
-
const commentResponses = responses.filter(r => (r.body as TypeCountBody).type === "comment");
|
|
169
|
-
|
|
170
|
-
expect(userResponses).toHaveLength(3);
|
|
171
|
-
expect(postResponses).toHaveLength(2);
|
|
172
|
-
expect(commentResponses).toHaveLength(2);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
And("the final state should show accurate counts for all properties", () => {
|
|
176
|
-
const userCounts = responses.filter(r => (r.body as TypeCountBody).type === "user").map(r => (r.body as TypeCountBody).count);
|
|
177
|
-
const postCounts = responses.filter(r => (r.body as TypeCountBody).type === "post").map(r => (r.body as TypeCountBody).count);
|
|
178
|
-
const commentCounts = responses.filter(r => (r.body as TypeCountBody).type === "comment").map(r => (r.body as TypeCountBody).count);
|
|
179
|
-
|
|
180
|
-
expect(Math.max(...userCounts)).toBe(3);
|
|
181
|
-
expect(Math.max(...postCounts)).toBe(2);
|
|
182
|
-
expect(Math.max(...commentCounts)).toBe(2);
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
Scenario("State isolation between different mock instances", ({ Given, When, Then, And }) => {
|
|
187
|
-
Given("I create two separate mock instances with independent state", () => {
|
|
188
|
-
mock1 = schmock({ state: { value: 10 } });
|
|
189
|
-
mock2 = schmock({ state: { value: 20 } });
|
|
190
|
-
|
|
191
|
-
mock1("GET /value", ({ state }) => ({ instance: 1, value: (state as ValueState).value }));
|
|
192
|
-
mock2("GET /value", ({ state }) => ({ instance: 2, value: (state as ValueState).value }));
|
|
193
|
-
|
|
194
|
-
mock1("POST /update", ({ state, body }) => {
|
|
195
|
-
const s = state as ValueState;
|
|
196
|
-
const b = body as UpdateBody;
|
|
197
|
-
s.value = b.newValue;
|
|
198
|
-
return { instance: 1, updated: s.value };
|
|
199
|
-
});
|
|
200
|
-
mock2("POST /update", ({ state, body }) => {
|
|
201
|
-
const s = state as ValueState;
|
|
202
|
-
const b = body as UpdateBody;
|
|
203
|
-
s.value = b.newValue;
|
|
204
|
-
return { instance: 2, updated: s.value };
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
When("I update state in both mock instances concurrently", async () => {
|
|
209
|
-
const promises = [
|
|
210
|
-
mock1.handle("POST", "/update", { body: { newValue: 100 } }),
|
|
211
|
-
mock2.handle("POST", "/update", { body: { newValue: 200 } }),
|
|
212
|
-
mock1.handle("GET", "/value"),
|
|
213
|
-
mock2.handle("GET", "/value"),
|
|
214
|
-
];
|
|
215
|
-
responses = await Promise.all(promises);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
Then("each mock instance should maintain its own isolated state", () => {
|
|
219
|
-
const instance1Responses = responses.filter(r => (r.body as InstanceBody).instance === 1);
|
|
220
|
-
const instance2Responses = responses.filter(r => (r.body as InstanceBody).instance === 2);
|
|
221
|
-
|
|
222
|
-
expect(instance1Responses).toHaveLength(2);
|
|
223
|
-
expect(instance2Responses).toHaveLength(2);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
And("changes in one instance should not affect the other", () => {
|
|
227
|
-
const instance1Get = responses.find(r => (r.body as InstanceBody).instance === 1 && (r.body as InstanceBody).value !== undefined);
|
|
228
|
-
const instance2Get = responses.find(r => (r.body as InstanceBody).instance === 2 && (r.body as InstanceBody).value !== undefined);
|
|
229
|
-
|
|
230
|
-
expect((instance1Get?.body as InstanceBody | undefined)?.value).toBe(100);
|
|
231
|
-
expect((instance2Get?.body as InstanceBody | undefined)?.value).toBe(200);
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
Scenario("Concurrent plugin state modifications", ({ Given, When, Then, And }) => {
|
|
236
|
-
Given("I create a mock with stateful counter and tracker plugins", () => {
|
|
237
|
-
mock = schmock({ state: { requestCount: 0, pluginData: {} } });
|
|
238
|
-
|
|
239
|
-
const plugin1: Plugin = {
|
|
240
|
-
name: "counter-plugin",
|
|
241
|
-
process: (ctx, response) => {
|
|
242
|
-
const rs = ctx.routeState! as PluginState;
|
|
243
|
-
rs.requestCount++;
|
|
244
|
-
rs.pluginData.plugin1 = (rs.pluginData.plugin1 || 0) + 1;
|
|
245
|
-
return {
|
|
246
|
-
context: ctx,
|
|
247
|
-
response: { ...(response as Record<string, unknown>), plugin1Count: rs.pluginData.plugin1 },
|
|
248
|
-
};
|
|
249
|
-
},
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const plugin2: Plugin = {
|
|
253
|
-
name: "tracker-plugin",
|
|
254
|
-
process: (ctx, response) => {
|
|
255
|
-
const rs = ctx.routeState! as PluginState;
|
|
256
|
-
rs.pluginData.plugin2 = (rs.pluginData.plugin2 || 0) + 1;
|
|
257
|
-
return {
|
|
258
|
-
context: ctx,
|
|
259
|
-
response: { ...(response as Record<string, unknown>), plugin2Count: rs.pluginData.plugin2 },
|
|
260
|
-
};
|
|
261
|
-
},
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
mock("GET /data", { base: "data" }).pipe(plugin1).pipe(plugin2);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
When("I make concurrent requests through the plugin pipeline", async () => {
|
|
268
|
-
const promises = Array.from({ length: 4 }, () =>
|
|
269
|
-
mock.handle("GET", "/data")
|
|
270
|
-
);
|
|
271
|
-
responses = await Promise.all(promises);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
Then("each plugin should correctly update its state counters", () => {
|
|
275
|
-
expect(responses).toHaveLength(4);
|
|
276
|
-
responses.forEach(response => {
|
|
277
|
-
const body = response.body as PluginResponseBody;
|
|
278
|
-
expect(response.status).toBe(200);
|
|
279
|
-
expect(typeof body.plugin1Count).toBe("number");
|
|
280
|
-
expect(typeof body.plugin2Count).toBe("number");
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// Each plugin should have counted all 4 requests
|
|
284
|
-
const plugin1Counts = responses.map(r => (r.body as PluginResponseBody).plugin1Count);
|
|
285
|
-
const plugin2Counts = responses.map(r => (r.body as PluginResponseBody).plugin2Count);
|
|
286
|
-
expect(Math.max(...plugin1Counts)).toBe(4);
|
|
287
|
-
expect(Math.max(...plugin2Counts)).toBe(4);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
And("the global request count should be accurate", () => {
|
|
291
|
-
// All 4 responses should have the base data preserved
|
|
292
|
-
expect(responses).toHaveLength(4);
|
|
293
|
-
responses.forEach(response => {
|
|
294
|
-
expect((response.body as PluginResponseBody).base).toBe("data");
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
And("plugin state should not interfere with each other", () => {
|
|
299
|
-
// plugin1Count and plugin2Count should increment independently
|
|
300
|
-
// Both should reach 4 since there are 4 requests
|
|
301
|
-
const plugin1Max = Math.max(...responses.map(r => (r.body as PluginResponseBody).plugin1Count));
|
|
302
|
-
const plugin2Max = Math.max(...responses.map(r => (r.body as PluginResponseBody).plugin2Count));
|
|
303
|
-
expect(plugin1Max).toBe(plugin2Max);
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
Scenario("State persistence across request contexts", ({ Given, When, Then, And }) => {
|
|
308
|
-
let sessionIds: string[] = [];
|
|
309
|
-
|
|
310
|
-
Given("I create a mock with persistent session state", () => {
|
|
311
|
-
mock = schmock({
|
|
312
|
-
state: {
|
|
313
|
-
sessions: {},
|
|
314
|
-
activeUsers: 0,
|
|
315
|
-
},
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
mock("POST /login", ({ state, body }) => {
|
|
319
|
-
const s = state as SessionState;
|
|
320
|
-
const b = body as LoginBody;
|
|
321
|
-
const sessionId = "session_" + Date.now();
|
|
322
|
-
s.sessions[sessionId] = {
|
|
323
|
-
user: b.username,
|
|
324
|
-
loginTime: new Date().toISOString(),
|
|
325
|
-
};
|
|
326
|
-
s.activeUsers++;
|
|
327
|
-
return { sessionId, message: "Logged in", activeUsers: s.activeUsers };
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
mock("GET /sessions/:sessionId", ({ state, params }) => {
|
|
331
|
-
const s = state as SessionState;
|
|
332
|
-
const session = s.sessions[params.sessionId];
|
|
333
|
-
return session
|
|
334
|
-
? { session, totalSessions: Object.keys(s.sessions).length }
|
|
335
|
-
: [404, { error: "Session not found" }];
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
mock("DELETE /sessions/:sessionId", ({ state, params }) => {
|
|
339
|
-
const s = state as SessionState;
|
|
340
|
-
if (s.sessions[params.sessionId]) {
|
|
341
|
-
delete s.sessions[params.sessionId];
|
|
342
|
-
s.activeUsers--;
|
|
343
|
-
return { message: "Logged out", activeUsers: s.activeUsers };
|
|
344
|
-
}
|
|
345
|
-
return [404, { error: "Session not found" }];
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
When("I simulate concurrent user login and logout operations", async () => {
|
|
350
|
-
// First, login some users
|
|
351
|
-
const loginPromises = [
|
|
352
|
-
mock.handle("POST", "/login", { body: { username: "user1" } }),
|
|
353
|
-
mock.handle("POST", "/login", { body: { username: "user2" } }),
|
|
354
|
-
mock.handle("POST", "/login", { body: { username: "user3" } }),
|
|
355
|
-
];
|
|
356
|
-
|
|
357
|
-
const loginResponses = await Promise.all(loginPromises);
|
|
358
|
-
sessionIds = loginResponses.map(r => (r.body as SessionResponseBody).sessionId);
|
|
359
|
-
|
|
360
|
-
// Then make concurrent operations
|
|
361
|
-
const promises = [
|
|
362
|
-
mock.handle("GET", `/sessions/${sessionIds[0]}`),
|
|
363
|
-
mock.handle("GET", `/sessions/${sessionIds[1]}`),
|
|
364
|
-
mock.handle("DELETE", `/sessions/${sessionIds[0]}`),
|
|
365
|
-
mock.handle("POST", "/login", { body: { username: "user4" } }),
|
|
366
|
-
];
|
|
367
|
-
|
|
368
|
-
responses = [...loginResponses, ...await Promise.all(promises)];
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
Then("session state should be maintained correctly across requests", () => {
|
|
372
|
-
const sessionGets = responses.filter(r => (r.body as SessionResponseBody).session);
|
|
373
|
-
expect(sessionGets.length).toBeGreaterThan(0);
|
|
374
|
-
|
|
375
|
-
sessionGets.forEach(response => {
|
|
376
|
-
const body = response.body as SessionResponseBody;
|
|
377
|
-
expect(body.session).toHaveProperty("user");
|
|
378
|
-
expect(body.session).toHaveProperty("loginTime");
|
|
379
|
-
});
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
And("active user count should remain consistent", () => {
|
|
383
|
-
const loginResponses = responses.filter(r => (r.body as SessionResponseBody).message === "Logged in");
|
|
384
|
-
const logoutResponses = responses.filter(r => (r.body as SessionResponseBody).message === "Logged out");
|
|
385
|
-
|
|
386
|
-
expect(loginResponses).toHaveLength(4); // 3 initial + 1 concurrent
|
|
387
|
-
expect(logoutResponses).toHaveLength(1);
|
|
388
|
-
|
|
389
|
-
// Due to concurrent execution, the exact count might vary
|
|
390
|
-
const finalActiveUsers = (logoutResponses[0]?.body as SessionResponseBody | undefined)?.activeUsers;
|
|
391
|
-
expect(finalActiveUsers).toBeGreaterThan(0);
|
|
392
|
-
expect(finalActiveUsers).toBeLessThan(4);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
And("session cleanup should work properly", () => {
|
|
396
|
-
const deleteResponse = responses.find(r => (r.body as SessionResponseBody).message === "Logged out");
|
|
397
|
-
expect(deleteResponse).toBeDefined();
|
|
398
|
-
expect((deleteResponse?.body as SessionResponseBody | undefined)?.activeUsers).toBeGreaterThanOrEqual(0);
|
|
399
|
-
});
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
Scenario("Large state object concurrent modifications", ({ Given, When, Then, And }) => {
|
|
403
|
-
Given("I create a mock with a large 1000-item data array", () => {
|
|
404
|
-
mock = schmock({
|
|
405
|
-
state: {
|
|
406
|
-
data: Array.from({ length: 1000 }, (_, i) => ({ id: i, value: 0 })),
|
|
407
|
-
},
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
mock("PATCH /data/:id", ({ state, params, body }) => {
|
|
411
|
-
const s = state as DataArrayState;
|
|
412
|
-
const b = body as DataUpdateBody;
|
|
413
|
-
const id = parseInt(params.id);
|
|
414
|
-
const item = s.data.find((d: DataItem) => d.id === id);
|
|
415
|
-
if (item) {
|
|
416
|
-
item.value = b.value;
|
|
417
|
-
return { id, updated: item.value, total: s.data.length };
|
|
418
|
-
}
|
|
419
|
-
return [404, { error: "Item not found" }];
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
mock("GET /data/stats", ({ state }) => {
|
|
423
|
-
const s = state as DataArrayState;
|
|
424
|
-
const total = s.data.reduce((sum: number, item: DataItem) => sum + item.value, 0);
|
|
425
|
-
const average = total / s.data.length;
|
|
426
|
-
return { total, average, items: s.data.length };
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
When("I make concurrent updates to different parts of large state", async () => {
|
|
431
|
-
const updatePromises = [
|
|
432
|
-
mock.handle("PATCH", "/data/0", { body: { value: 10 } }),
|
|
433
|
-
mock.handle("PATCH", "/data/100", { body: { value: 20 } }),
|
|
434
|
-
mock.handle("PATCH", "/data/500", { body: { value: 30 } }),
|
|
435
|
-
mock.handle("PATCH", "/data/999", { body: { value: 40 } }),
|
|
436
|
-
mock.handle("GET", "/data/stats"),
|
|
437
|
-
];
|
|
438
|
-
|
|
439
|
-
responses = await Promise.all(updatePromises);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
Then("all updates should be applied correctly", () => {
|
|
443
|
-
const updateResponses = responses.filter(r => (r.body as DataUpdateResponseBody).updated !== undefined);
|
|
444
|
-
expect(updateResponses).toHaveLength(4);
|
|
445
|
-
|
|
446
|
-
updateResponses.forEach((response, index) => {
|
|
447
|
-
const expectedValues = [10, 20, 30, 40];
|
|
448
|
-
expect((response.body as DataUpdateResponseBody).updated).toBe(expectedValues[index]);
|
|
449
|
-
});
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
And("statistics should reflect the correct aggregated values", () => {
|
|
453
|
-
const statsResponse = responses.find(r => r.body && (r.body as StatsResponseBody).total !== undefined);
|
|
454
|
-
expect(statsResponse).toBeDefined();
|
|
455
|
-
|
|
456
|
-
// Due to concurrent execution, the stats might be calculated before all updates complete
|
|
457
|
-
// Just verify the structure and that the total makes sense
|
|
458
|
-
const statsBody = statsResponse?.body as StatsResponseBody | undefined;
|
|
459
|
-
if (statsBody?.total !== undefined) {
|
|
460
|
-
expect(statsBody.total).toBeGreaterThanOrEqual(0);
|
|
461
|
-
}
|
|
462
|
-
if (statsBody?.average !== undefined) {
|
|
463
|
-
expect(statsBody.average).toBeGreaterThanOrEqual(0);
|
|
464
|
-
}
|
|
465
|
-
if (statsBody?.items !== undefined) {
|
|
466
|
-
expect(statsBody.items).toBe(1000);
|
|
467
|
-
}
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
And("no data corruption should occur", () => {
|
|
471
|
-
responses.forEach(response => {
|
|
472
|
-
const body = response.body as Record<string, unknown>;
|
|
473
|
-
expect(response.status).toBe(200);
|
|
474
|
-
if (body && body.total !== undefined && body.items !== undefined) {
|
|
475
|
-
expect(body.items).toBe(1000); // Array length should remain unchanged
|
|
476
|
-
}
|
|
477
|
-
if (body && body.updated !== undefined) {
|
|
478
|
-
expect(typeof body.updated).toBe("number");
|
|
479
|
-
if (body.total !== undefined) {
|
|
480
|
-
expect(body.total).toBe(1000);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
});
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
Scenario("State cleanup and memory management", ({ Given, When, Then, And }) => {
|
|
488
|
-
Given("I create a mock with LRU cache state", () => {
|
|
489
|
-
mock = schmock({
|
|
490
|
-
state: {
|
|
491
|
-
cache: {},
|
|
492
|
-
cacheSize: 0,
|
|
493
|
-
maxCacheSize: 5,
|
|
494
|
-
},
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
mock("POST /cache/:key", ({ state, params, body }) => {
|
|
498
|
-
const s = state as CacheState;
|
|
499
|
-
const b = body as CacheBody;
|
|
500
|
-
// Simple LRU-like cache with size limit
|
|
501
|
-
if (s.cacheSize >= s.maxCacheSize) {
|
|
502
|
-
const oldestKey = Object.keys(s.cache)[0];
|
|
503
|
-
delete s.cache[oldestKey];
|
|
504
|
-
s.cacheSize--;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
s.cache[params.key] = {
|
|
508
|
-
value: b.value,
|
|
509
|
-
timestamp: Date.now(),
|
|
510
|
-
};
|
|
511
|
-
s.cacheSize++;
|
|
512
|
-
|
|
513
|
-
return {
|
|
514
|
-
key: params.key,
|
|
515
|
-
cached: true,
|
|
516
|
-
cacheSize: s.cacheSize,
|
|
517
|
-
};
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
mock("GET /cache/:key", ({ state, params }) => {
|
|
521
|
-
const s = state as CacheState;
|
|
522
|
-
const item = s.cache[params.key];
|
|
523
|
-
return item
|
|
524
|
-
? { key: params.key, ...item }
|
|
525
|
-
: [404, { error: "Not found in cache" }];
|
|
526
|
-
});
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
When("I add items to cache beyond the size limit concurrently", async () => {
|
|
530
|
-
const promises = Array.from({ length: 8 }, (_, i) =>
|
|
531
|
-
mock.handle("POST", `/cache/item${i}`, { body: { value: `value${i}` } })
|
|
532
|
-
);
|
|
533
|
-
responses = await Promise.all(promises);
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
Then("no response should report a cache size above 5", () => {
|
|
537
|
-
responses.forEach(response => {
|
|
538
|
-
expect((response.body as CacheResponseBody).cacheSize).toBeLessThanOrEqual(5);
|
|
539
|
-
});
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
And("the final cache size should be 5", () => {
|
|
543
|
-
const finalCacheSize = Math.max(...responses.map(r => (r.body as CacheResponseBody).cacheSize));
|
|
544
|
-
expect(finalCacheSize).toBe(5);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
And("each response should confirm the item was cached", () => {
|
|
548
|
-
responses.forEach(response => {
|
|
549
|
-
const body = response.body as CacheResponseBody;
|
|
550
|
-
expect(body.cached).toBe(true);
|
|
551
|
-
expect(typeof body.cacheSize).toBe("number");
|
|
552
|
-
expect(body.cacheSize).toBeGreaterThan(0);
|
|
553
|
-
});
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
Scenario("Nested state object concurrent access", ({ Given, When, Then, And }) => {
|
|
558
|
-
Given("I create a mock with deeply nested user state", () => {
|
|
559
|
-
mock = schmock({
|
|
560
|
-
state: {
|
|
561
|
-
users: {
|
|
562
|
-
profiles: {},
|
|
563
|
-
preferences: {},
|
|
564
|
-
activity: {},
|
|
565
|
-
},
|
|
566
|
-
},
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
mock("PUT /users/:id/profile", ({ state, params, body }) => {
|
|
570
|
-
const s = state as NestedUsersState;
|
|
571
|
-
if (!s.users.profiles[params.id]) {
|
|
572
|
-
s.users.profiles[params.id] = {};
|
|
573
|
-
}
|
|
574
|
-
Object.assign(s.users.profiles[params.id], body);
|
|
575
|
-
return { userId: params.id, profile: s.users.profiles[params.id] };
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
mock("PUT /users/:id/preferences", ({ state, params, body }) => {
|
|
579
|
-
const s = state as NestedUsersState;
|
|
580
|
-
s.users.preferences[params.id] = { ...(body as Record<string, unknown>), updatedAt: Date.now() };
|
|
581
|
-
return { userId: params.id, preferences: s.users.preferences[params.id] };
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
mock("POST /users/:id/activity", ({ state, params, body }) => {
|
|
585
|
-
const s = state as NestedUsersState;
|
|
586
|
-
if (!s.users.activity[params.id]) {
|
|
587
|
-
s.users.activity[params.id] = [];
|
|
588
|
-
}
|
|
589
|
-
s.users.activity[params.id].push({ ...(body as Record<string, unknown>), timestamp: Date.now() });
|
|
590
|
-
return {
|
|
591
|
-
userId: params.id,
|
|
592
|
-
activityCount: s.users.activity[params.id].length,
|
|
593
|
-
};
|
|
594
|
-
});
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
When("I make concurrent updates to different nested state sections", async () => {
|
|
598
|
-
const promises = [
|
|
599
|
-
mock.handle("PUT", "/users/1/profile", { body: { name: "User 1", age: 25 } }),
|
|
600
|
-
mock.handle("PUT", "/users/1/preferences", { body: { theme: "dark", language: "en" } }),
|
|
601
|
-
mock.handle("POST", "/users/1/activity", { body: { action: "login" } }),
|
|
602
|
-
mock.handle("PUT", "/users/2/profile", { body: { name: "User 2", age: 30 } }),
|
|
603
|
-
mock.handle("POST", "/users/2/activity", { body: { action: "view_page" } }),
|
|
604
|
-
mock.handle("POST", "/users/1/activity", { body: { action: "logout" } }),
|
|
605
|
-
];
|
|
606
|
-
|
|
607
|
-
responses = await Promise.all(promises);
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
Then("each nested section should be updated independently", () => {
|
|
611
|
-
const profileUpdates = responses.filter(r => (r.body as ProfileResponseBody).profile);
|
|
612
|
-
const preferenceUpdates = responses.filter(r => (r.body as ProfileResponseBody).preferences);
|
|
613
|
-
const activityUpdates = responses.filter(r => (r.body as ProfileResponseBody).activityCount);
|
|
614
|
-
|
|
615
|
-
expect(profileUpdates).toHaveLength(2);
|
|
616
|
-
expect(preferenceUpdates).toHaveLength(1);
|
|
617
|
-
expect(activityUpdates).toHaveLength(3);
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
And("user 1 should have 4 updates and user 2 should have 2", () => {
|
|
621
|
-
const user1Updates = responses.filter(r => (r.body as ProfileResponseBody).userId === "1");
|
|
622
|
-
const user2Updates = responses.filter(r => (r.body as ProfileResponseBody).userId === "2");
|
|
623
|
-
|
|
624
|
-
expect(user1Updates).toHaveLength(4); // profile, preferences, 2 activities
|
|
625
|
-
expect(user2Updates).toHaveLength(2); // profile, activity
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
And("user 1 activity count should be 2", () => {
|
|
629
|
-
const activityUpdates = responses.filter(r => (r.body as ProfileResponseBody).activityCount);
|
|
630
|
-
const user1Activities = activityUpdates.filter(r => (r.body as ProfileResponseBody).userId === "1");
|
|
631
|
-
expect(user1Activities.length).toBe(2);
|
|
632
|
-
expect((user1Activities[user1Activities.length - 1].body as ProfileResponseBody).activityCount).toBe(2);
|
|
633
|
-
});
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
Scenario("State rollback on plugin errors", ({ Given, When, Then, And }) => {
|
|
637
|
-
Given("I create a mock with an error-prone transaction plugin", () => {
|
|
638
|
-
mock = schmock({ state: { transactions: [], balance: 100 } });
|
|
639
|
-
|
|
640
|
-
const transactionPlugin: Plugin = {
|
|
641
|
-
name: "transaction-plugin",
|
|
642
|
-
process: (ctx, response) => {
|
|
643
|
-
const b = ctx.body as TransactionBody;
|
|
644
|
-
const rs = ctx.routeState! as TransactionState;
|
|
645
|
-
const amount = b.amount;
|
|
646
|
-
const currentBalance = rs.balance;
|
|
647
|
-
|
|
648
|
-
// Simulate transaction processing
|
|
649
|
-
if (amount > currentBalance) {
|
|
650
|
-
throw new Error("Insufficient funds");
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
rs.balance -= amount;
|
|
654
|
-
rs.transactions.push({
|
|
655
|
-
amount,
|
|
656
|
-
balanceAfter: rs.balance,
|
|
657
|
-
timestamp: Date.now(),
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
return {
|
|
661
|
-
context: ctx,
|
|
662
|
-
response: {
|
|
663
|
-
success: true,
|
|
664
|
-
newBalance: rs.balance,
|
|
665
|
-
transactionCount: rs.transactions.length,
|
|
666
|
-
},
|
|
667
|
-
};
|
|
668
|
-
},
|
|
669
|
-
onError: (error, ctx) => {
|
|
670
|
-
const rs = ctx.routeState! as TransactionState;
|
|
671
|
-
// Don't modify state on error - let it rollback naturally
|
|
672
|
-
return {
|
|
673
|
-
status: 400,
|
|
674
|
-
body: { error: error.message, balance: rs.balance },
|
|
675
|
-
headers: {},
|
|
676
|
-
};
|
|
677
|
-
},
|
|
678
|
-
};
|
|
679
|
-
|
|
680
|
-
mock("POST /transaction", { initialBalance: 100 }).pipe(transactionPlugin);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
When("I make concurrent transactions including some that should fail", async () => {
|
|
684
|
-
const promises = [
|
|
685
|
-
mock.handle("POST", "/transaction", { body: { amount: 20 } }), // Should succeed
|
|
686
|
-
mock.handle("POST", "/transaction", { body: { amount: 30 } }), // Should succeed
|
|
687
|
-
mock.handle("POST", "/transaction", { body: { amount: 150 } }), // Should fail
|
|
688
|
-
mock.handle("POST", "/transaction", { body: { amount: 25 } }), // Should succeed
|
|
689
|
-
mock.handle("POST", "/transaction", { body: { amount: 100 } }), // May succeed or fail depending on order
|
|
690
|
-
];
|
|
691
|
-
|
|
692
|
-
responses = await Promise.all(promises);
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
Then("successful transactions should update state correctly", () => {
|
|
696
|
-
const successfulTransactions = responses.filter(r => r.status === 200 && (r.body as TransactionResponseBody).success);
|
|
697
|
-
|
|
698
|
-
// Some transactions might succeed or fail depending on concurrency
|
|
699
|
-
successfulTransactions.forEach(response => {
|
|
700
|
-
const body = response.body as TransactionResponseBody;
|
|
701
|
-
expect(body.success).toBe(true);
|
|
702
|
-
expect(body.newBalance).toBeDefined();
|
|
703
|
-
expect(typeof body.newBalance).toBe("number");
|
|
704
|
-
});
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
And("failed transactions should not modify state", () => {
|
|
708
|
-
const failedTransactions = responses.filter(r => r.status >= 400);
|
|
709
|
-
|
|
710
|
-
failedTransactions.forEach(response => {
|
|
711
|
-
const body = response.body as TransactionResponseBody;
|
|
712
|
-
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
713
|
-
expect(response.body).toBeDefined();
|
|
714
|
-
if (body.error) {
|
|
715
|
-
expect(typeof body.error).toBe("string");
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
And("final balance should reflect only successful transactions", () => {
|
|
721
|
-
const successfulTransactions = responses.filter(r => (r.body as TransactionResponseBody).success === true);
|
|
722
|
-
if (successfulTransactions.length > 0) {
|
|
723
|
-
const balances = successfulTransactions.map(r => (r.body as TransactionResponseBody).newBalance);
|
|
724
|
-
const finalBalance = Math.min(...balances); // Last successful transaction
|
|
725
|
-
expect(finalBalance).toBeLessThan(100);
|
|
726
|
-
expect(finalBalance).toBeGreaterThanOrEqual(0);
|
|
727
|
-
}
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
And("the transaction count should match the number of successful transactions", () => {
|
|
731
|
-
const successfulTransactions = responses.filter(r => (r.body as TransactionResponseBody).success === true);
|
|
732
|
-
if (successfulTransactions.length > 0) {
|
|
733
|
-
const transactionCounts = successfulTransactions.map(r => (r.body as TransactionResponseBody).transactionCount);
|
|
734
|
-
const maxCount = Math.max(...transactionCounts);
|
|
735
|
-
expect(maxCount).toBe(successfulTransactions.length);
|
|
736
|
-
}
|
|
737
|
-
});
|
|
738
|
-
});
|
|
739
|
-
});
|