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