@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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/abort.d.ts +3 -0
  4. package/dist/abort.d.ts.map +1 -0
  5. package/dist/abort.js +30 -0
  6. package/dist/binary.d.ts +8 -0
  7. package/dist/binary.d.ts.map +1 -0
  8. package/dist/binary.js +9 -0
  9. package/dist/builder.d.ts +31 -10
  10. package/dist/builder.d.ts.map +1 -1
  11. package/dist/builder.js +804 -203
  12. package/dist/constants.d.ts +38 -0
  13. package/dist/constants.d.ts.map +1 -1
  14. package/dist/constants.js +123 -2
  15. package/dist/errors.d.ts +14 -3
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +41 -6
  18. package/dist/helpers.d.ts +9 -0
  19. package/dist/helpers.d.ts.map +1 -1
  20. package/dist/helpers.js +18 -2
  21. package/dist/http-helpers.d.ts +52 -5
  22. package/dist/http-helpers.d.ts.map +1 -1
  23. package/dist/http-helpers.js +153 -37
  24. package/dist/index.d.ts +381 -60
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -3
  27. package/dist/interceptor.d.ts +11 -1
  28. package/dist/interceptor.d.ts.map +1 -1
  29. package/dist/interceptor.js +354 -156
  30. package/dist/parser.d.ts.map +1 -1
  31. package/dist/parser.js +12 -3
  32. package/dist/plugin-pipeline.d.ts +11 -4
  33. package/dist/plugin-pipeline.d.ts.map +1 -1
  34. package/dist/plugin-pipeline.js +117 -39
  35. package/dist/response-normalizer.d.ts +16 -0
  36. package/dist/response-normalizer.d.ts.map +1 -0
  37. package/dist/response-normalizer.js +316 -0
  38. package/dist/response-parser.d.ts.map +1 -1
  39. package/dist/response-parser.js +85 -18
  40. package/dist/route-matcher.d.ts +3 -0
  41. package/dist/route-matcher.d.ts.map +1 -1
  42. package/dist/route-matcher.js +12 -6
  43. package/dist/types.d.ts +7 -2
  44. package/dist/types.d.ts.map +1 -1
  45. package/package.json +18 -8
  46. package/src/audit-core-builder.test.ts +0 -218
  47. package/src/audit-response-guard.test.ts +0 -38
  48. package/src/builder.test.ts +0 -289
  49. package/src/builder.ts +0 -701
  50. package/src/constants.test.ts +0 -99
  51. package/src/constants.ts +0 -73
  52. package/src/debug.test.ts +0 -241
  53. package/src/delay.test.ts +0 -319
  54. package/src/dist-shape.test.ts +0 -35
  55. package/src/errors.test.ts +0 -223
  56. package/src/errors.ts +0 -130
  57. package/src/factory.test.ts +0 -133
  58. package/src/helpers.test.ts +0 -147
  59. package/src/helpers.ts +0 -58
  60. package/src/http-helpers.ts +0 -113
  61. package/src/index.ts +0 -151
  62. package/src/interceptor.test.ts +0 -291
  63. package/src/interceptor.ts +0 -320
  64. package/src/namespace.test.ts +0 -274
  65. package/src/parser.property.test.ts +0 -594
  66. package/src/parser.test.ts +0 -148
  67. package/src/parser.ts +0 -64
  68. package/src/plugin-pipeline.ts +0 -103
  69. package/src/plugin-system.test.ts +0 -602
  70. package/src/response-parser.ts +0 -75
  71. package/src/response-parsing.test.ts +0 -333
  72. package/src/route-matcher.ts +0 -69
  73. package/src/route-matching.test.ts +0 -394
  74. package/src/server.test.ts +0 -165
  75. package/src/smart-defaults.test.ts +0 -361
  76. package/src/steps/async-support.steps.ts +0 -400
  77. package/src/steps/audit-core-builder.steps.ts +0 -188
  78. package/src/steps/audit-onerror-tuple.steps.ts +0 -99
  79. package/src/steps/basic-usage.steps.ts +0 -245
  80. package/src/steps/developer-experience.steps.ts +0 -204
  81. package/src/steps/error-handling.steps.ts +0 -345
  82. package/src/steps/fluent-api.steps.ts +0 -255
  83. package/src/steps/http-methods.steps.ts +0 -331
  84. package/src/steps/interceptor.steps.ts +0 -260
  85. package/src/steps/lifecycle-events.steps.ts +0 -142
  86. package/src/steps/performance-reliability.steps.ts +0 -423
  87. package/src/steps/plugin-integration.steps.ts +0 -280
  88. package/src/steps/request-history.steps.ts +0 -276
  89. package/src/steps/response-delay.steps.ts +0 -88
  90. package/src/steps/route-key-format.steps.ts +0 -99
  91. package/src/steps/standalone-server.steps.ts +0 -233
  92. package/src/steps/state-concurrency.steps.ts +0 -739
  93. package/src/steps/stateful-workflows.steps.ts +0 -353
  94. 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
- });