@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.
Files changed (45) hide show
  1. package/dist/binary.d.ts +8 -0
  2. package/dist/binary.d.ts.map +1 -0
  3. package/dist/binary.js +9 -0
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +60 -29
  6. package/dist/http-helpers.d.ts +15 -3
  7. package/dist/http-helpers.d.ts.map +1 -1
  8. package/dist/http-helpers.js +24 -6
  9. package/dist/index.d.ts +90 -27
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/interceptor.d.ts.map +1 -1
  13. package/dist/interceptor.js +71 -20
  14. package/dist/plugin-pipeline.d.ts +11 -4
  15. package/dist/plugin-pipeline.d.ts.map +1 -1
  16. package/dist/plugin-pipeline.js +98 -35
  17. package/dist/response-parser.d.ts.map +1 -1
  18. package/dist/response-parser.js +47 -17
  19. package/package.json +5 -3
  20. package/src/binary.test.ts +126 -0
  21. package/src/binary.ts +11 -0
  22. package/src/builder.ts +82 -35
  23. package/src/http-helpers.test.ts +39 -0
  24. package/src/http-helpers.ts +46 -10
  25. package/src/index.ts +1 -0
  26. package/src/interceptor.test.ts +197 -0
  27. package/src/interceptor.ts +215 -128
  28. package/src/parser.property.test.ts +91 -55
  29. package/src/plugin-pipeline.ts +150 -45
  30. package/src/plugin-system.test.ts +95 -0
  31. package/src/response-parser.ts +58 -19
  32. package/src/server.test.ts +81 -12
  33. package/src/steps/async-support.steps.ts +29 -8
  34. package/src/steps/audit-onerror-tuple.steps.ts +14 -30
  35. package/src/steps/error-handling.steps.ts +463 -253
  36. package/src/steps/fetch-interceptor.steps.ts +457 -0
  37. package/src/steps/http-methods.steps.ts +19 -8
  38. package/src/steps/lifecycle-events.steps.ts +50 -43
  39. package/src/steps/performance-reliability.steps.ts +78 -411
  40. package/src/steps/plugin-integration.steps.ts +54 -0
  41. package/src/steps/request-history.steps.ts +412 -230
  42. package/src/steps/standalone-server.steps.ts +7 -13
  43. package/src/steps/state-concurrency.steps.ts +142 -714
  44. package/src/steps/interceptor.steps.ts +0 -260
  45. package/src/steps/stateful-workflows.steps.ts +0 -353
@@ -1,423 +1,90 @@
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";
5
4
 
6
- const feature = await loadFeature("../../features/performance-reliability.feature");
7
-
8
- describeFeature(feature, ({ Scenario }) => {
9
- let mock: CallableMockInstance;
10
- let responses: any[] = [];
11
- let responsesTimes: number[] = [];
12
- let expectedResponseCount = 0;
13
-
14
- Scenario("High-volume concurrent requests", ({ Given, When, Then, And }) => {
15
- responses = [];
16
- responsesTimes = [];
17
-
18
- Given("I create a mock for high-volume load testing", () => {
19
- mock = schmock();
20
- mock('GET /api/health', () => ({
21
- status: 'healthy',
22
- timestamp: Date.now()
23
- }));
24
- mock('GET /api/data/:id', ({ params }) => ({
25
- id: params.id,
26
- data: Array.from({ length: 50 }, (_, i) => ({
27
- index: i,
28
- value: Math.random()
29
- })),
30
- generated_at: new Date().toISOString()
31
- }));
32
- mock('POST /api/process', ({ body }) => {
33
- const items = Array.isArray(body) ? body : [body];
34
- return {
35
- processed: items.length,
36
- results: items.map(item => ({ ...item, processed: true })),
37
- batch_id: Math.random().toString(36)
38
- };
39
- });
40
- });
41
-
42
- When("I send {int} concurrent {string} requests", async (_, count: number, request: string) => {
43
- const [method, path] = request.split(" ");
44
- responses = [];
45
- responsesTimes = [];
46
- expectedResponseCount = count;
47
-
48
- const promises = Array.from({ length: count }, async () => {
49
- const startTime = Date.now();
50
- const response = await mock.handle(method as any, path);
51
- const elapsed = Date.now() - startTime;
52
- return { response, elapsed };
53
- });
54
-
55
- const results = await Promise.all(promises);
56
- responses = results.map(r => r.response);
57
- responsesTimes = results.map(r => r.elapsed);
58
- });
59
-
60
- Then("all concurrent requests should complete successfully", () => {
61
- expect(responses).toHaveLength(expectedResponseCount);
62
- for (const response of responses) {
63
- expect(response.status).toBe(200);
64
- }
65
- });
66
-
67
- // Generous ceiling, not a perf target: in-process handlers are sub-millisecond,
68
- // so this only guards against a pathological hang. A tight bound (e.g. 50ms)
69
- // flakes under CI load where 100 concurrent requests contend for the event loop.
70
- And("the average response time should be under {int}ms", (_, maxTime: number) => {
71
- const avgTime = responsesTimes.reduce((a, b) => a + b, 0) / responsesTimes.length;
72
- expect(avgTime).toBeLessThan(maxTime);
73
- });
74
-
75
- And("no requests should timeout", () => {
76
- // All requests completed if we got here, so no timeouts
77
- expect(responses.length).toBeGreaterThan(0);
78
- });
79
-
80
- When("I send {int} concurrent requests to different {string} endpoints", async (_, count: number, pathPattern: string) => {
81
- responses = [];
82
-
83
- const promises = Array.from({ length: count }, async (_, i) => {
84
- const path = pathPattern.replace(":id", `id${i}`);
85
- return await mock.handle("GET", path);
86
- });
87
-
88
- responses = await Promise.all(promises);
89
- });
90
-
91
- Then("all responses should be unique based on ID", () => {
92
- const ids = responses.map(r => r.body.id);
93
- const uniqueIds = new Set(ids);
94
- expect(uniqueIds.size).toBe(responses.length);
95
- });
96
-
97
- And("each response should contain {int} data items", (_, expectedCount: number) => {
98
- for (const response of responses) {
99
- expect(response.body.data).toHaveLength(expectedCount);
100
- }
101
- });
102
-
103
- When("I send {int} concurrent {string} requests with different payloads", async (_, count: number, request: string) => {
104
- const [method, path] = request.split(" ");
105
- responses = [];
106
- expectedResponseCount = count;
107
-
108
- const promises = Array.from({ length: count }, async (_, i) => {
109
- return await mock.handle(method as any, path, {
110
- body: { id: i, data: `payload-${i}` }
111
- });
112
- });
113
-
114
- responses = await Promise.all(promises);
115
- });
116
-
117
- And("all concurrent requests should complete successfully", () => {
118
- expect(responses).toHaveLength(expectedResponseCount);
119
- for (const response of responses) {
120
- expect(response.status).toBe(200);
121
- }
122
- });
123
-
124
- And("each response should have a unique batch_id", () => {
125
- const batchIds = responses.map(r => r.body.batch_id);
126
- const uniqueBatchIds = new Set(batchIds);
127
- expect(uniqueBatchIds.size).toBe(responses.length);
128
- });
5
+ const feature = await loadFeature(
6
+ "../../features/performance-reliability.feature",
7
+ );
8
+
9
+ function withTimeout(
10
+ promise: Promise<void>,
11
+ timeoutMs: number,
12
+ message: string,
13
+ ): Promise<void> {
14
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
15
+ const timeout = new Promise<never>((_, reject) => {
16
+ timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
129
17
  });
130
18
 
131
- Scenario("Memory usage under sustained load", ({ Given, When, Then, And }) => {
132
- Given("I create a mock with large payload handling", () => {
133
- mock = schmock();
134
- mock('POST /api/large-data', ({ body }) => {
135
- const largeArray = Array.from({ length: 1000 }, (_, i) => ({
136
- id: i,
137
- data: 'x'.repeat(100),
138
- timestamp: Date.now(),
139
- payload: body
140
- }));
141
- return {
142
- results: largeArray,
143
- items: largeArray,
144
- total_size: largeArray.length,
145
- size: 'large',
146
- memory_usage: process.memoryUsage ? process.memoryUsage() : null
147
- };
148
- });
149
- mock('GET /api/accumulate/:count', ({ params }) => {
150
- const count = parseInt(params.count);
151
- const items = Array.from({ length: count }, (_, i) => ({
152
- id: i,
153
- value: Math.random(),
154
- timestamp: Date.now()
155
- }));
156
- return {
157
- items: items,
158
- accumulated: items,
159
- total: items.length,
160
- count: items.length,
161
- memory_usage: process.memoryUsage ? process.memoryUsage() : null
162
- };
163
- });
164
- });
165
-
166
- When("I send {int} requests to {string} with {int}KB payloads", async (_, count: number, request: string, payloadSize: number) => {
167
- const [method, path] = request.split(" ");
168
- responses = [];
169
-
170
- const largePayload = { data: 'x'.repeat(payloadSize * 1024) };
171
-
172
- for (let i = 0; i < count; i++) {
173
- const response = await mock.handle(method as any, path, { body: largePayload });
174
- responses.push(response);
175
- }
176
- });
177
-
178
- Then("all requests should complete without memory errors", () => {
179
- expect(responses).toHaveLength(20);
180
- for (const response of responses) {
181
- expect(response.status).toBe(200);
182
- expect(response.body.items).toHaveLength(1000);
183
- }
184
- });
185
-
186
- And("each response should confirm large payload processing", () => {
187
- expect(responses.every(r => r.body.size === 'large')).toBe(true);
188
- });
189
-
190
- When("I request {string} multiple times", async (_, request: string) => {
191
- const [method, path] = request.split(" ");
192
- responses = [];
193
-
194
- for (let i = 0; i < 5; i++) {
195
- const response = await mock.handle(method as any, path);
196
- responses.push(response);
197
- }
198
- });
199
-
200
- Then("each response should contain {int} accumulated items", (_, expectedCount: number) => {
201
- for (const response of responses) {
202
- expect(response.body.accumulated).toHaveLength(expectedCount);
203
- expect(response.body.total).toBe(expectedCount);
204
- }
205
- });
206
-
207
- And("all accumulation requests should complete successfully", () => {
208
- expect(responses).toHaveLength(5);
209
- });
19
+ return Promise.race([promise, timeout]).finally(() => {
20
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
210
21
  });
22
+ }
211
23
 
212
- Scenario("Error resilience and recovery", ({ Given, When, Then, And }) => {
213
- responses = [];
214
-
215
- Given("I create a mock with intermittent failure simulation", () => {
216
- mock = schmock();
217
- let requestCount = 0;
218
- mock('POST /api/unreliable', ({ body }) => {
219
- requestCount++;
220
- if (requestCount % 5 === 0) {
221
- return [500, { error: 'Simulated server error', request_id: requestCount }];
222
- }
223
- return [200, { success: true, data: body, request_id: requestCount }];
224
- });
225
- mock('GET /api/flaky', () => {
226
- requestCount++;
227
- if (requestCount % 5 === 0) {
228
- return [500, { error: 'Flaky service error', request_id: requestCount }];
229
- }
230
- return [200, { success: true, request_id: requestCount }];
231
- });
232
- mock('POST /api/validate-strict', ({ body }) => {
233
- if (!body || typeof body !== 'object') {
234
- return [400, { error: 'Request body is required and must be an object', code: 'INVALID_BODY' }];
235
- }
236
- const record = body as Record<string, unknown>;
237
- if (!record.name || typeof record.name !== 'string') {
238
- return [422, { error: 'Name field is required and must be a string', code: 'INVALID_NAME' }];
239
- }
240
- if (!record.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(record.email as string)) {
241
- return [422, { error: 'Valid email address is required', code: 'INVALID_EMAIL' }];
242
- }
243
- return [200, { message: 'Validation successful', data: body }];
24
+ describeFeature(feature, ({ Scenario }) => {
25
+ let mock: Schmock.CallableMockInstance;
26
+ let releaseBarrier: (() => void) | undefined;
27
+ let boundedBarrier: Promise<void> | undefined;
28
+ let arrivals = 0;
29
+ let responses: Schmock.Response[] = [];
30
+ let expectedIds: string[] = [];
31
+
32
+ Scenario(
33
+ "Concurrent request contexts remain isolated",
34
+ ({ Given, When, Then, And }) => {
35
+ Given(
36
+ "I create an asynchronous mock that waits until every request reaches a shared rendezvous",
37
+ () => {
38
+ mock = schmock();
39
+ arrivals = 0;
40
+ mock("GET /items/:id", async ({ params }) => {
41
+ arrivals += 1;
42
+ if (arrivals === expectedIds.length) releaseBarrier?.();
43
+ if (!boundedBarrier) {
44
+ throw new Error("Concurrent rendezvous was not initialized");
45
+ }
46
+ await boundedBarrier;
47
+ return { id: params.id };
48
+ });
49
+ },
50
+ );
51
+
52
+ When(
53
+ "I issue {int} concurrent requests with distinct route IDs",
54
+ async (_scenario, count: number) => {
55
+ expectedIds = Array.from({ length: count }, (_, index) =>
56
+ String(index),
57
+ );
58
+ const barrier = new Promise<void>((resolve) => {
59
+ releaseBarrier = resolve;
60
+ });
61
+ boundedBarrier = withTimeout(
62
+ barrier,
63
+ 2_000,
64
+ `Timed out waiting for ${count} requests to reach the shared rendezvous`,
65
+ );
66
+ const pending = expectedIds.map((id) =>
67
+ mock.handle("GET", `/items/${id}`),
68
+ );
69
+ responses = await Promise.all(pending);
70
+ },
71
+ );
72
+
73
+ Then("every response should contain its corresponding route ID", () => {
74
+ expect(arrivals).toBe(expectedIds.length);
75
+ expect(responses).toHaveLength(expectedIds.length);
76
+ expect(responses.map((response) => response.body)).toEqual(
77
+ expectedIds.map((id) => ({ id })),
78
+ );
244
79
  });
245
- });
246
-
247
- When("I send {int} requests to {string}", async (_, count: number, request: string) => {
248
- const [method, path] = request.split(" ");
249
- responses = [];
250
-
251
- for (let i = 0; i < count; i++) {
252
- const response = await mock.handle(method as any, path);
253
- responses.push(response);
254
- }
255
- });
256
-
257
- Then("some requests should succeed and some should fail", () => {
258
- const successCount = responses.filter(r => r.status === 200).length;
259
- const errorCount = responses.filter(r => r.status >= 400).length;
260
-
261
- expect(successCount).toBeGreaterThan(0);
262
- expect(errorCount).toBeGreaterThan(0);
263
- expect(successCount + errorCount).toBe(responses.length);
264
- });
265
-
266
- And("the success rate should be between {int}% and {int}%", (_, low: number, high: number) => {
267
- const successCount = responses.filter(r => r.status === 200).length;
268
- const actualRate = (successCount / responses.length) * 100;
269
-
270
- expect(actualRate).toBeGreaterThanOrEqual(low);
271
- expect(actualRate).toBeLessThanOrEqual(high);
272
- });
273
-
274
- And("error responses should have appropriate status codes", () => {
275
- const errorResponses = responses.filter(r => r.status >= 400);
276
- const validErrorCodes = [429, 500, 503];
277
-
278
- for (const response of errorResponses) {
279
- expect(validErrorCodes).toContain(response.status);
280
- }
281
- });
282
-
283
- When("I send requests to {string} with various invalid inputs", async (_, request: string) => {
284
- const [method, path] = request.split(" ");
285
- responses = [];
286
80
 
287
- // Test various invalid scenarios
288
- const testCases: Schmock.RequestOptions[] = [
289
- { headers: {}, body: null }, // No content-type, no body
290
- { headers: { 'content-type': 'application/json' }, body: null }, // No body
291
- { headers: { 'content-type': 'application/json' }, body: "invalid" }, // Invalid body type
292
- { headers: { 'content-type': 'application/json' }, body: {} }, // Missing required field
293
- ];
294
-
295
- for (const testCase of testCases) {
296
- const response = await mock.handle(method as any, path, testCase);
297
- responses.push(response);
298
- }
299
- });
300
-
301
- Then("each error response should have a non-empty error message", () => {
302
- for (const response of responses) {
303
- expect(response.status).toBeGreaterThanOrEqual(400);
304
- expect(response.body.error).toBeDefined();
305
- expect(typeof response.body.error).toBe('string');
306
- expect(response.body.error.length).toBeGreaterThan(0);
307
- }
308
- });
309
-
310
- And("each error response should have the correct status code", () => {
311
- expect(responses[0].status).toBe(400); // No content-type
312
- expect(responses[1].status).toBe(400); // No body
313
- expect(responses[2].status).toBe(400); // Invalid body type
314
- expect(responses[3].status).toBe(422); // Missing required field
315
- });
316
- });
317
-
318
- Scenario("Route matching performance with complex patterns", ({ Given, When, Then, And }) => {
319
- responses = [];
320
-
321
- Given("I create a mock with many nested route patterns", () => {
322
- mock = schmock();
323
- mock('GET /api/users', () => ({ type: 'users-list' }));
324
- mock('GET /api/users/:id', ({ params }) => ({ type: 'user', id: params.id }));
325
- mock('GET /api/users/:userId/posts', ({ params }) => ({ type: 'user-posts', userId: params.userId }));
326
- mock('GET /api/users/:userId/posts/:postId', ({ params }) => ({
327
- type: 'user-post',
328
- userId: params.userId,
329
- postId: params.postId
330
- }));
331
- mock('GET /api/users/:userId/posts/:postId/comments', ({ params }) => ({
332
- type: 'post-comments',
333
- userId: params.userId,
334
- postId: params.postId
335
- }));
336
- mock('GET /api/posts', () => ({ type: 'posts-list' }));
337
- mock('GET /api/posts/:postId', ({ params }) => ({ type: 'post', postId: params.postId }));
338
- mock('GET /api/posts/:postId/comments/:commentId', ({ params }) => ({
339
- type: 'comment',
340
- postId: params.postId,
341
- commentId: params.commentId
342
- }));
343
- mock('GET /static/:category/:file', ({ params }) => ({
344
- type: 'static',
345
- category: params.category,
346
- file: params.file
347
- }));
348
- mock('GET /api/v2/users/:userId', ({ params }) => ({
349
- type: 'versioned-user',
350
- userId: params.userId,
351
- version: 'v2'
352
- }));
353
- });
354
-
355
- When("I send requests to all route patterns simultaneously", async () => {
356
- const testPaths = [
357
- "GET /api/users",
358
- "GET /api/users/123",
359
- "GET /api/users/123/posts",
360
- "GET /api/users/123/posts/456",
361
- "GET /api/users/123/posts/456/comments",
362
- "GET /api/posts",
363
- "GET /api/posts/789",
364
- "GET /api/posts/789/comments/101",
365
- "GET /static/images/logo.png",
366
- "GET /api/v2/users/456"
367
- ];
368
-
369
- const promises = testPaths.map(async (request) => {
370
- const [method, path] = request.split(" ");
371
- return await mock.handle(method as any, path);
81
+ And("the history should contain each route ID exactly once", () => {
82
+ const retainedIds = mock
83
+ .history()
84
+ .map((record) => record.params.id)
85
+ .sort();
86
+ expect(retainedIds).toEqual([...expectedIds].sort());
372
87
  });
373
-
374
- responses = await Promise.all(promises);
375
- });
376
-
377
- Then("each request should match the correct route pattern", () => {
378
- expect(responses[0].body.type).toBe("users-list");
379
- expect(responses[1].body.type).toBe("user");
380
- expect(responses[2].body.type).toBe("user-posts");
381
- expect(responses[3].body.type).toBe("user-post");
382
- expect(responses[4].body.type).toBe("post-comments");
383
- expect(responses[5].body.type).toBe("posts-list");
384
- expect(responses[6].body.type).toBe("post");
385
- expect(responses[7].body.type).toBe("comment");
386
- expect(responses[8].body.type).toBe("static");
387
- expect(responses[9].body.type).toBe("versioned-user");
388
- });
389
-
390
- And("parameter extraction should work correctly for all patterns", () => {
391
- expect(responses[1].body.id).toBe("123");
392
- expect(responses[2].body.userId).toBe("123");
393
- expect(responses[3].body.userId).toBe("123");
394
- expect(responses[3].body.postId).toBe("456");
395
- expect(responses[8].body.category).toBe("images");
396
- expect(responses[8].body.file).toBe("logo.png");
397
- expect(responses[9].body.version).toBe("v2");
398
- });
399
-
400
- When("I send requests to non-matching paths", async () => {
401
- const invalidPaths = [
402
- "GET /api/invalid",
403
- "GET /users", // Missing /api prefix
404
- "GET /api/users/123/invalid",
405
- "POST /static/images/test.jpg" // Wrong method
406
- ];
407
-
408
- responses = [];
409
- for (const request of invalidPaths) {
410
- const [method, path] = request.split(" ");
411
- const response = await mock.handle(method as any, path);
412
- responses.push(response);
413
- }
414
- });
415
-
416
- Then("they should consistently return {int} responses", (_, expectedStatus: number) => {
417
- for (const response of responses) {
418
- expect(response.status).toBe(expectedStatus);
419
- }
420
- });
421
- });
422
-
88
+ },
89
+ );
423
90
  });
@@ -8,6 +8,7 @@ const feature = await loadFeature("../../features/plugin-integration.feature");
8
8
  describeFeature(feature, ({ Scenario }) => {
9
9
  let mock: CallableMockInstance;
10
10
  let requestResponses: any[] = [];
11
+ let guardedGeneratorExecutions = 0;
11
12
 
12
13
  Scenario("Plugin state sharing with pipeline", ({ Given, When, Then, And }) => {
13
14
  requestResponses = [];
@@ -167,6 +168,59 @@ describeFeature(feature, ({ Scenario }) => {
167
168
  });
168
169
  });
169
170
 
171
+ Scenario(
172
+ "Request guard prevents route side effects",
173
+ ({ Given, When, Then, And }) => {
174
+ Given(
175
+ "I create a mock whose guarded generator records each execution",
176
+ () => {
177
+ guardedGeneratorExecutions = 0;
178
+ mock = schmock();
179
+ mock("POST /guarded", () => {
180
+ guardedGeneratorExecutions += 1;
181
+ return [201, { created: true }];
182
+ });
183
+
184
+ const guardPlugin = {
185
+ name: "pre-request-auth",
186
+ beforeRequest(context: Schmock.PluginContext) {
187
+ if (!context.headers.authorization) {
188
+ return {
189
+ context,
190
+ response: [
191
+ 401,
192
+ { error: "Unauthorized", code: "AUTH_REQUIRED" },
193
+ ],
194
+ };
195
+ }
196
+ return { context };
197
+ },
198
+ process(context: Schmock.PluginContext, response?: unknown) {
199
+ return { context, response };
200
+ },
201
+ };
202
+
203
+ mock.pipe(guardPlugin);
204
+ },
205
+ );
206
+
207
+ When(
208
+ "I request the guarded route without authorization",
209
+ async () => {
210
+ requestResponses = [await mock.handle("POST", "/guarded")];
211
+ },
212
+ );
213
+
214
+ Then("the guarded response status should be {int}", (_, status: number) => {
215
+ expect(requestResponses[0].status).toBe(status);
216
+ });
217
+
218
+ And("the guarded generator should not have executed", () => {
219
+ expect(guardedGeneratorExecutions).toBe(0);
220
+ });
221
+ },
222
+ );
223
+
170
224
  Scenario(
171
225
  "Pipeline order and response transformation",
172
226
  ({ Given, When, Then }) => {