@schmock/core 2.2.2 → 2.3.0

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.
Files changed (45) hide show
  1. package/dist/binary.d.ts +8 -0
  2. package/dist/binary.d.ts.map +1 -0
  3. package/dist/binary.js +9 -0
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +60 -29
  6. package/dist/http-helpers.d.ts +15 -3
  7. package/dist/http-helpers.d.ts.map +1 -1
  8. package/dist/http-helpers.js +24 -6
  9. package/dist/index.d.ts +90 -27
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/interceptor.d.ts.map +1 -1
  13. package/dist/interceptor.js +71 -20
  14. package/dist/plugin-pipeline.d.ts +11 -4
  15. package/dist/plugin-pipeline.d.ts.map +1 -1
  16. package/dist/plugin-pipeline.js +98 -35
  17. package/dist/response-parser.d.ts.map +1 -1
  18. package/dist/response-parser.js +47 -17
  19. package/package.json +5 -3
  20. package/src/binary.test.ts +126 -0
  21. package/src/binary.ts +11 -0
  22. package/src/builder.ts +82 -35
  23. package/src/http-helpers.test.ts +39 -0
  24. package/src/http-helpers.ts +46 -10
  25. package/src/index.ts +1 -0
  26. package/src/interceptor.test.ts +197 -0
  27. package/src/interceptor.ts +215 -128
  28. package/src/parser.property.test.ts +91 -55
  29. package/src/plugin-pipeline.ts +150 -45
  30. package/src/plugin-system.test.ts +95 -0
  31. package/src/response-parser.ts +58 -19
  32. package/src/server.test.ts +81 -12
  33. package/src/steps/async-support.steps.ts +29 -8
  34. package/src/steps/audit-onerror-tuple.steps.ts +14 -30
  35. package/src/steps/error-handling.steps.ts +463 -253
  36. package/src/steps/fetch-interceptor.steps.ts +457 -0
  37. package/src/steps/http-methods.steps.ts +19 -8
  38. package/src/steps/lifecycle-events.steps.ts +50 -43
  39. package/src/steps/performance-reliability.steps.ts +78 -411
  40. package/src/steps/plugin-integration.steps.ts +54 -0
  41. package/src/steps/request-history.steps.ts +412 -230
  42. package/src/steps/standalone-server.steps.ts +7 -13
  43. package/src/steps/state-concurrency.steps.ts +142 -714
  44. package/src/steps/interceptor.steps.ts +0 -260
  45. package/src/steps/stateful-workflows.steps.ts +0 -353
@@ -1,739 +1,167 @@
1
1
  import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
2
2
  import { expect } from "vitest";
3
3
  import { schmock } from "../index";
4
- import type { CallableMockInstance, Plugin } from "../types";
5
4
 
6
- // ---------- State shape interfaces for each scenario ----------
7
- // Index signatures ensure compatibility with Record<string, unknown>
5
+ const feature = await loadFeature("../../features/state-concurrency.feature");
8
6
 
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;
7
+ function readCounter(state: Record<string, unknown>): number {
8
+ const counter = state.counter;
9
+ if (typeof counter !== "number") throw new Error("Counter state is invalid");
10
+ return counter;
46
11
  }
47
- interface TransactionBody { amount: number }
48
12
 
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;
13
+ function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
15
  }
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
16
 
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);
17
+ function responseRecord(response: Schmock.Response): Record<string, unknown> {
18
+ if (!isRecord(response.body)) {
19
+ throw new Error("Expected an object response");
20
+ }
21
+ return response.body;
22
+ }
179
23
 
180
- expect(Math.max(...userCounts)).toBe(3);
181
- expect(Math.max(...postCounts)).toBe(2);
182
- expect(Math.max(...commentCounts)).toBe(2);
183
- });
24
+ function withTimeout(
25
+ promise: Promise<void>,
26
+ timeoutMs: number,
27
+ message: string,
28
+ ): Promise<void> {
29
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
30
+ const timeout = new Promise<never>((_, reject) => {
31
+ timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
184
32
  });
185
33
 
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
- });
34
+ return Promise.race([promise, timeout]).finally(() => {
35
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
233
36
  });
37
+ }
234
38
 
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 },
39
+ describeFeature(feature, ({ Scenario }) => {
40
+ let firstMock: Schmock.CallableMockInstance;
41
+ let secondMock: Schmock.CallableMockInstance;
42
+ let firstResponse: Schmock.Response;
43
+ let secondResponse: Schmock.Response;
44
+ let concurrentResponses: Schmock.Response[] = [];
45
+ let expectedConcurrentCount = 0;
46
+
47
+ Scenario(
48
+ "Different mock instances keep independent route state",
49
+ ({ Given, When, Then, And }) => {
50
+ Given("I create two mocks with different counter state", () => {
51
+ firstMock = schmock({ state: { counter: 0 } });
52
+ secondMock = schmock({ state: { counter: 100 } });
53
+
54
+ const registerIncrement = (mock: Schmock.CallableMockInstance) => {
55
+ mock("POST /increment", ({ state }) => {
56
+ const next = readCounter(state) + 1;
57
+ state.counter = next;
58
+ return { counter: next };
59
+ });
60
+ };
61
+ registerIncrement(firstMock);
62
+ registerIncrement(secondMock);
63
+ });
64
+
65
+ When("I increment both mocks concurrently", async () => {
66
+ [firstResponse, secondResponse] = await Promise.all([
67
+ firstMock.handle("POST", "/increment"),
68
+ secondMock.handle("POST", "/increment"),
69
+ ]);
70
+ });
71
+
72
+ Then("the first mock counter should be {int}", (_, counter: number) => {
73
+ expect(responseRecord(firstResponse).counter).toBe(counter);
74
+ expect(firstMock.getState().counter).toBe(counter);
75
+ });
76
+
77
+ And("the second mock counter should be {int}", (_, counter: number) => {
78
+ expect(responseRecord(secondResponse).counter).toBe(counter);
79
+ expect(secondMock.getState().counter).toBe(counter);
80
+ });
81
+ },
82
+ );
83
+
84
+ Scenario(
85
+ "Concurrent requests keep plugin state isolated",
86
+ ({ Given, When, Then }) => {
87
+ let requestCount = 0;
88
+ let arrivals = 0;
89
+ let boundedBarrier: Promise<void> | undefined;
90
+ let releaseBarrier: (() => void) | undefined;
91
+
92
+ Given(
93
+ "I create a two-stage plugin pipeline with a shared async barrier",
94
+ () => {
95
+ arrivals = 0;
96
+
97
+ const firstStage: Schmock.Plugin = {
98
+ name: "first-stage",
99
+ async process(context, response) {
100
+ context.state.set("request-id", context.params.id);
101
+ arrivals += 1;
102
+ if (arrivals === requestCount) releaseBarrier?.();
103
+ if (!boundedBarrier) {
104
+ throw new Error("Plugin rendezvous was not initialized");
105
+ }
106
+ await boundedBarrier;
107
+ return { context, response };
108
+ },
248
109
  };
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 },
110
+ const secondStage: Schmock.Plugin = {
111
+ name: "second-stage",
112
+ process(context, response) {
113
+ const firstStageId = context.state.get("request-id");
114
+ return {
115
+ context,
116
+ response: {
117
+ routeId: context.params.id,
118
+ firstStageId,
119
+ original: response,
120
+ },
121
+ };
122
+ },
260
123
  };
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
124
 
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 })),
125
+ firstMock = schmock();
126
+ firstMock("GET /work/:id", ({ params }) => ({ id: params.id }))
127
+ .pipe(firstStage)
128
+ .pipe(secondStage);
407
129
  },
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
130
  );
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
131
 
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
- },
132
+ When(
133
+ "I issue {int} concurrent requests with distinct IDs through the pipeline",
134
+ async (_scenario, count: number) => {
135
+ requestCount = count;
136
+ expectedConcurrentCount = count;
137
+ const barrier = new Promise<void>((resolve) => {
138
+ releaseBarrier = resolve;
139
+ });
140
+ boundedBarrier = withTimeout(
141
+ barrier,
142
+ 2_000,
143
+ `Timed out waiting for ${count} requests to reach the plugin rendezvous`,
144
+ );
145
+ concurrentResponses = await Promise.all(
146
+ Array.from({ length: count }, (_, index) =>
147
+ firstMock.handle("GET", `/work/${index}`),
148
+ ),
149
+ );
566
150
  },
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
- }
151
+ );
652
152
 
653
- rs.balance -= amount;
654
- rs.transactions.push({
655
- amount,
656
- balanceAfter: rs.balance,
657
- timestamp: Date.now(),
153
+ Then(
154
+ "every response should contain the same ID from both plugin stages",
155
+ () => {
156
+ expect(arrivals).toBe(expectedConcurrentCount);
157
+ expect(concurrentResponses).toHaveLength(expectedConcurrentCount);
158
+ concurrentResponses.forEach((response, index) => {
159
+ const body = responseRecord(response);
160
+ expect(body.routeId).toBe(String(index));
161
+ expect(body.firstStageId).toBe(String(index));
658
162
  });
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
163
  },
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
- });
164
+ );
165
+ },
166
+ );
739
167
  });