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