@schmock/core 2.2.3 → 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.
- package/dist/binary.d.ts +8 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +9 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +60 -29
- package/dist/http-helpers.d.ts +15 -3
- package/dist/http-helpers.d.ts.map +1 -1
- package/dist/http-helpers.js +24 -6
- package/dist/index.d.ts +90 -27
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +71 -20
- package/dist/plugin-pipeline.d.ts +11 -4
- package/dist/plugin-pipeline.d.ts.map +1 -1
- package/dist/plugin-pipeline.js +98 -35
- package/dist/response-parser.d.ts.map +1 -1
- package/dist/response-parser.js +47 -17
- package/package.json +5 -3
- package/src/binary.test.ts +126 -0
- package/src/binary.ts +11 -0
- package/src/builder.ts +82 -35
- package/src/http-helpers.test.ts +39 -0
- package/src/http-helpers.ts +46 -10
- package/src/index.ts +1 -0
- package/src/interceptor.test.ts +197 -0
- package/src/interceptor.ts +215 -128
- package/src/parser.property.test.ts +91 -55
- package/src/plugin-pipeline.ts +150 -45
- package/src/plugin-system.test.ts +95 -0
- package/src/response-parser.ts +58 -19
- package/src/server.test.ts +81 -12
- package/src/steps/async-support.steps.ts +29 -8
- package/src/steps/audit-onerror-tuple.steps.ts +14 -30
- package/src/steps/error-handling.steps.ts +463 -253
- package/src/steps/fetch-interceptor.steps.ts +457 -0
- package/src/steps/http-methods.steps.ts +19 -8
- package/src/steps/lifecycle-events.steps.ts +50 -43
- package/src/steps/performance-reliability.steps.ts +78 -411
- package/src/steps/plugin-integration.steps.ts +54 -0
- package/src/steps/request-history.steps.ts +412 -230
- package/src/steps/standalone-server.steps.ts +7 -13
- package/src/steps/state-concurrency.steps.ts +142 -714
- package/src/steps/interceptor.steps.ts +0 -260
- 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
|
-
|
|
7
|
-
// Index signatures ensure compatibility with Record<string, unknown>
|
|
5
|
+
const feature = await loadFeature("../../features/state-concurrency.feature");
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
});
|