@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.
Files changed (48) hide show
  1. package/dist/builder.d.ts +62 -0
  2. package/dist/builder.d.ts.map +1 -0
  3. package/dist/builder.js +432 -0
  4. package/dist/errors.d.ts +56 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/errors.js +92 -0
  7. package/dist/index.d.ts +27 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/parser.d.ts +19 -0
  11. package/dist/parser.d.ts.map +1 -0
  12. package/dist/parser.js +40 -0
  13. package/dist/types.d.ts +15 -0
  14. package/dist/types.d.ts.map +1 -0
  15. package/dist/types.js +2 -0
  16. package/package.json +39 -0
  17. package/src/builder.d.ts.map +1 -0
  18. package/src/builder.test.ts +289 -0
  19. package/src/builder.ts +580 -0
  20. package/src/debug.test.ts +241 -0
  21. package/src/delay.test.ts +319 -0
  22. package/src/errors.d.ts.map +1 -0
  23. package/src/errors.test.ts +223 -0
  24. package/src/errors.ts +124 -0
  25. package/src/factory.test.ts +133 -0
  26. package/src/index.d.ts.map +1 -0
  27. package/src/index.ts +80 -0
  28. package/src/namespace.test.ts +273 -0
  29. package/src/parser.d.ts.map +1 -0
  30. package/src/parser.test.ts +131 -0
  31. package/src/parser.ts +61 -0
  32. package/src/plugin-system.test.ts +511 -0
  33. package/src/response-parsing.test.ts +255 -0
  34. package/src/route-matching.test.ts +351 -0
  35. package/src/smart-defaults.test.ts +361 -0
  36. package/src/steps/async-support.steps.ts +427 -0
  37. package/src/steps/basic-usage.steps.ts +316 -0
  38. package/src/steps/developer-experience.steps.ts +439 -0
  39. package/src/steps/error-handling.steps.ts +387 -0
  40. package/src/steps/fluent-api.steps.ts +252 -0
  41. package/src/steps/http-methods.steps.ts +397 -0
  42. package/src/steps/performance-reliability.steps.ts +459 -0
  43. package/src/steps/plugin-integration.steps.ts +279 -0
  44. package/src/steps/route-key-format.steps.ts +118 -0
  45. package/src/steps/state-concurrency.steps.ts +643 -0
  46. package/src/steps/stateful-workflows.steps.ts +351 -0
  47. package/src/types.d.ts.map +1 -0
  48. package/src/types.ts +17 -0
@@ -0,0 +1,511 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { schmock } from "./index";
3
+
4
+ describe("plugin system", () => {
5
+ describe("plugin registration", () => {
6
+ it("registers plugins in order", async () => {
7
+ const mock = schmock();
8
+ const executionOrder: string[] = [];
9
+
10
+ const plugin1 = {
11
+ name: "first",
12
+ process: (ctx: any, res: any) => {
13
+ executionOrder.push("first");
14
+ return { context: ctx, response: res };
15
+ },
16
+ };
17
+
18
+ const plugin2 = {
19
+ name: "second",
20
+ process: (ctx: any, res: any) => {
21
+ executionOrder.push("second");
22
+ return { context: ctx, response: res };
23
+ },
24
+ };
25
+
26
+ mock("GET /test", "response").pipe(plugin1).pipe(plugin2);
27
+
28
+ await mock.handle("GET", "/test");
29
+ expect(executionOrder).toEqual(["first", "second"]);
30
+ });
31
+
32
+ it("stores plugin metadata", async () => {
33
+ const mock = schmock({ debug: true });
34
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
35
+
36
+ const plugin = {
37
+ name: "test-plugin",
38
+ version: "1.2.3",
39
+ process: (ctx: any, res: any) => ({ context: ctx, response: res }),
40
+ };
41
+
42
+ mock.pipe(plugin);
43
+
44
+ // Check actual console calls - should have at least plugin registration
45
+ expect(consoleSpy).toHaveBeenCalled();
46
+
47
+ // Check that plugin registration was logged
48
+ const pluginCall = consoleSpy.mock.calls.find((call) =>
49
+ call[0].includes("[SCHMOCK:PLUGIN]"),
50
+ );
51
+
52
+ expect(pluginCall).toBeDefined();
53
+ expect(pluginCall[0]).toContain("Registered plugin: test-plugin@1.2.3");
54
+ expect(pluginCall[1]).toMatchObject({
55
+ name: "test-plugin",
56
+ version: "1.2.3",
57
+ hasProcess: true,
58
+ hasOnError: false,
59
+ });
60
+
61
+ consoleSpy.mockRestore();
62
+ });
63
+
64
+ it("handles plugins without version", async () => {
65
+ const mock = schmock({ debug: true });
66
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
67
+
68
+ const plugin = {
69
+ name: "no-version",
70
+ process: (ctx: any, res: any) => ({ context: ctx, response: res }),
71
+ };
72
+
73
+ mock.pipe(plugin);
74
+
75
+ // Check that plugin without version was logged
76
+ const pluginCall = consoleSpy.mock.calls.find(
77
+ (call) =>
78
+ call[0].includes("[SCHMOCK:PLUGIN]") &&
79
+ call[0].includes("no-version@unknown"),
80
+ );
81
+
82
+ expect(pluginCall).toBeDefined();
83
+
84
+ consoleSpy.mockRestore();
85
+ });
86
+ });
87
+
88
+ describe("plugin execution pipeline", () => {
89
+ it("passes context between plugins", async () => {
90
+ const mock = schmock();
91
+ let receivedContext: any;
92
+
93
+ const plugin1 = {
94
+ name: "setter",
95
+ process: (ctx: any, res: any) => {
96
+ ctx.state.set("modified", "plugin1");
97
+ return { context: ctx, response: res };
98
+ },
99
+ };
100
+
101
+ const plugin2 = {
102
+ name: "getter",
103
+ process: (ctx: any, res: any) => {
104
+ receivedContext = ctx;
105
+ return { context: ctx, response: res };
106
+ },
107
+ };
108
+
109
+ mock("GET /test", "original").pipe(plugin1).pipe(plugin2);
110
+
111
+ await mock.handle("GET", "/test");
112
+ expect(receivedContext.state.get("modified")).toBe("plugin1");
113
+ });
114
+
115
+ it("allows first plugin to generate response", async () => {
116
+ const mock = schmock();
117
+
118
+ const plugin = {
119
+ name: "generator",
120
+ process: (ctx: any, _res: any) => {
121
+ return { context: ctx, response: "plugin-generated" };
122
+ },
123
+ };
124
+
125
+ mock("GET /test", null).pipe(plugin);
126
+
127
+ const response = await mock.handle("GET", "/test");
128
+ expect(response.body).toBe("plugin-generated");
129
+ });
130
+
131
+ it("allows later plugins to transform response", async () => {
132
+ const mock = schmock();
133
+
134
+ const plugin1 = {
135
+ name: "generator",
136
+ process: (ctx: any, res: any) => {
137
+ return { context: ctx, response: res || "initial" };
138
+ },
139
+ };
140
+
141
+ const plugin2 = {
142
+ name: "transformer",
143
+ process: (ctx: any, res: any) => {
144
+ return { context: ctx, response: `transformed-${res}` };
145
+ },
146
+ };
147
+
148
+ mock("GET /test", "original").pipe(plugin1).pipe(plugin2);
149
+
150
+ const response = await mock.handle("GET", "/test");
151
+ expect(response.body).toBe("transformed-original");
152
+ });
153
+
154
+ it("preserves response if plugin doesn't modify it", async () => {
155
+ const mock = schmock();
156
+
157
+ const plugin = {
158
+ name: "passthrough",
159
+ process: (ctx: any, res: any) => {
160
+ ctx.state.set("processed", true);
161
+ return { context: ctx, response: res };
162
+ },
163
+ };
164
+
165
+ mock("GET /test", "unchanged").pipe(plugin);
166
+
167
+ const response = await mock.handle("GET", "/test");
168
+ expect(response.body).toBe("unchanged");
169
+ });
170
+ });
171
+
172
+ describe("plugin context", () => {
173
+ it("provides complete plugin context", async () => {
174
+ const mock = schmock();
175
+ let pluginContext: any;
176
+
177
+ const plugin = {
178
+ name: "inspector",
179
+ process: (ctx: any, res: any) => {
180
+ pluginContext = ctx;
181
+ return { context: ctx, response: res };
182
+ },
183
+ };
184
+
185
+ mock("GET /users/:id", "response").pipe(plugin);
186
+
187
+ await mock.handle("GET", "/users/123", {
188
+ query: { limit: "10" },
189
+ headers: { authorization: "Bearer token" },
190
+ body: { test: "data" },
191
+ });
192
+
193
+ expect(pluginContext).toMatchObject({
194
+ path: "/users/123",
195
+ method: "GET",
196
+ params: { id: "123" },
197
+ query: { limit: "10" },
198
+ headers: { authorization: "Bearer token" },
199
+ body: { test: "data" },
200
+ });
201
+ expect(pluginContext.state).toBeInstanceOf(Map);
202
+ expect(pluginContext.route).toBeDefined();
203
+ });
204
+
205
+ it("provides route configuration in context", async () => {
206
+ const mock = schmock();
207
+ let routeConfig: any;
208
+
209
+ const plugin = {
210
+ name: "config-reader",
211
+ process: (ctx: any, res: any) => {
212
+ routeConfig = ctx.route;
213
+ return { context: ctx, response: res };
214
+ },
215
+ };
216
+
217
+ mock("GET /test", "response", {
218
+ contentType: "text/plain",
219
+ custom: "value",
220
+ }).pipe(plugin);
221
+
222
+ await mock.handle("GET", "/test");
223
+
224
+ expect(routeConfig).toMatchObject({
225
+ contentType: "text/plain",
226
+ custom: "value",
227
+ });
228
+ });
229
+
230
+ it("isolates state between requests", async () => {
231
+ const mock = schmock();
232
+ const states: any[] = [];
233
+
234
+ const plugin = {
235
+ name: "state-tracker",
236
+ process: (ctx: any, res: any) => {
237
+ ctx.state.set("requestId", Math.random());
238
+ states.push(ctx.state.get("requestId"));
239
+ return { context: ctx, response: res };
240
+ },
241
+ };
242
+
243
+ mock("GET /test", "response").pipe(plugin);
244
+
245
+ await mock.handle("GET", "/test");
246
+ await mock.handle("GET", "/test");
247
+
248
+ expect(states).toHaveLength(2);
249
+ expect(states[0]).not.toBe(states[1]);
250
+ });
251
+ });
252
+
253
+ describe("plugin error handling", () => {
254
+ it("throws PluginError when plugin process fails", async () => {
255
+ const mock = schmock();
256
+
257
+ const plugin = {
258
+ name: "failing-plugin",
259
+ process: () => {
260
+ throw new Error("Plugin failed");
261
+ },
262
+ };
263
+
264
+ mock("GET /test", "response").pipe(plugin);
265
+
266
+ const response = await mock.handle("GET", "/test");
267
+ expect(response.status).toBe(500);
268
+ expect(response.body.error).toContain('Plugin "failing-plugin" failed');
269
+ expect(response.body.code).toBe("PLUGIN_ERROR");
270
+ });
271
+
272
+ it("calls onError hook when plugin fails", async () => {
273
+ const mock = schmock();
274
+ const onErrorSpy = vi.fn();
275
+
276
+ const plugin = {
277
+ name: "recoverable-plugin",
278
+ process: () => {
279
+ throw new Error("Initial failure");
280
+ },
281
+ onError: (error: Error, ctx: any) => {
282
+ onErrorSpy(error, ctx);
283
+ return { status: 200, body: "recovered", headers: {} };
284
+ },
285
+ };
286
+
287
+ mock("GET /test", "response").pipe(plugin);
288
+
289
+ const response = await mock.handle("GET", "/test");
290
+
291
+ expect(onErrorSpy).toHaveBeenCalledWith(
292
+ expect.objectContaining({ message: "Initial failure" }),
293
+ expect.any(Object),
294
+ );
295
+ expect(response.status).toBe(200);
296
+ expect(response.body).toBe("recovered");
297
+ });
298
+
299
+ it("continues with error propagation if onError doesn't return response", async () => {
300
+ const mock = schmock();
301
+
302
+ const plugin = {
303
+ name: "non-recovering-plugin",
304
+ process: () => {
305
+ throw new Error("Plugin failed");
306
+ },
307
+ onError: (_error: Error, _ctx: any) => {
308
+ // Just log, don't return response
309
+ return undefined;
310
+ },
311
+ };
312
+
313
+ mock("GET /test", "response").pipe(plugin);
314
+
315
+ const response = await mock.handle("GET", "/test");
316
+ expect(response.status).toBe(500);
317
+ expect(response.body.code).toBe("PLUGIN_ERROR");
318
+ });
319
+
320
+ it("handles onError hook failures", async () => {
321
+ const mock = schmock({ debug: true });
322
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
323
+
324
+ const plugin = {
325
+ name: "broken-error-handler",
326
+ process: () => {
327
+ throw new Error("Initial failure");
328
+ },
329
+ onError: () => {
330
+ throw new Error("Error handler failed");
331
+ },
332
+ };
333
+
334
+ mock("GET /test", "response").pipe(plugin);
335
+
336
+ const response = await mock.handle("GET", "/test");
337
+
338
+ // Check that error handler failure was logged
339
+ const errorCall = consoleSpy.mock.calls.find(
340
+ (call) =>
341
+ call[0].includes("[SCHMOCK:PIPELINE]") &&
342
+ call[0].includes("error handler failed"),
343
+ );
344
+
345
+ expect(errorCall).toBeDefined();
346
+ expect(response.status).toBe(500);
347
+
348
+ consoleSpy.mockRestore();
349
+ });
350
+
351
+ it("validates plugin returns proper result structure", async () => {
352
+ const mock = schmock();
353
+
354
+ const plugin = {
355
+ name: "invalid-plugin",
356
+ process: () => {
357
+ return { invalidStructure: true }; // Missing context
358
+ },
359
+ };
360
+
361
+ mock("GET /test", "response").pipe(plugin);
362
+
363
+ const response = await mock.handle("GET", "/test");
364
+ expect(response.status).toBe(500);
365
+ expect(response.body.error).toContain("didn't return valid result");
366
+ });
367
+
368
+ it("handles plugin returning null/undefined", async () => {
369
+ const mock = schmock();
370
+
371
+ const plugin = {
372
+ name: "null-plugin",
373
+ process: () => null,
374
+ };
375
+
376
+ mock("GET /test", "response").pipe(plugin);
377
+
378
+ const response = await mock.handle("GET", "/test");
379
+ expect(response.status).toBe(500);
380
+ expect(response.body.error).toContain("didn't return valid result");
381
+ });
382
+ });
383
+
384
+ describe("async plugin support", () => {
385
+ it("handles async plugin process methods", async () => {
386
+ const mock = schmock();
387
+
388
+ const plugin = {
389
+ name: "async-plugin",
390
+ process: async (ctx: any, res: any) => {
391
+ await new Promise((resolve) => setTimeout(resolve, 10));
392
+ return { context: ctx, response: `async-${res}` };
393
+ },
394
+ };
395
+
396
+ mock("GET /test", "response").pipe(plugin);
397
+
398
+ const response = await mock.handle("GET", "/test");
399
+ expect(response.body).toBe("async-response");
400
+ });
401
+
402
+ it("handles async onError hooks", async () => {
403
+ const mock = schmock();
404
+
405
+ const plugin = {
406
+ name: "async-error-plugin",
407
+ process: () => {
408
+ throw new Error("Async failure");
409
+ },
410
+ onError: async (_error: Error, _ctx: any) => {
411
+ await new Promise((resolve) => setTimeout(resolve, 10));
412
+ return { status: 200, body: "async-recovered", headers: {} };
413
+ },
414
+ };
415
+
416
+ mock("GET /test", "response").pipe(plugin);
417
+
418
+ const response = await mock.handle("GET", "/test");
419
+ expect(response.body).toBe("async-recovered");
420
+ });
421
+ });
422
+
423
+ describe("debug logging", () => {
424
+ it("logs plugin pipeline execution with debug enabled", async () => {
425
+ const mock = schmock({ debug: true });
426
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
427
+
428
+ const plugin = {
429
+ name: "logged-plugin",
430
+ process: (ctx: any, res: any) => ({ context: ctx, response: res }),
431
+ };
432
+
433
+ mock("GET /test", "response").pipe(plugin);
434
+
435
+ await mock.handle("GET", "/test");
436
+
437
+ // Check that pipeline execution was logged
438
+ const pipelineCall = consoleSpy.mock.calls.find(
439
+ (call) =>
440
+ call[0].includes("[SCHMOCK:PIPELINE]") &&
441
+ call[0].includes("Running plugin pipeline for 1 plugins"),
442
+ );
443
+ const processingCall = consoleSpy.mock.calls.find(
444
+ (call) =>
445
+ call[0].includes("[SCHMOCK:PIPELINE]") &&
446
+ call[0].includes("Processing plugin: logged-plugin"),
447
+ );
448
+
449
+ expect(pipelineCall).toBeDefined();
450
+ expect(processingCall).toBeDefined();
451
+
452
+ consoleSpy.mockRestore();
453
+ });
454
+
455
+ it("logs plugin response generation", async () => {
456
+ const mock = schmock({ debug: true });
457
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
458
+
459
+ const plugin = {
460
+ name: "generator-plugin",
461
+ process: (ctx: any, _res: any) => ({
462
+ context: ctx,
463
+ response: "generated",
464
+ }),
465
+ };
466
+
467
+ mock("GET /test", null).pipe(plugin);
468
+
469
+ await mock.handle("GET", "/test");
470
+
471
+ // Check that plugin response generation was logged
472
+ const generatedCall = consoleSpy.mock.calls.find(
473
+ (call) =>
474
+ call[0].includes("[SCHMOCK:PIPELINE]") &&
475
+ call[0].includes("Plugin generator-plugin generated response"),
476
+ );
477
+
478
+ expect(generatedCall).toBeDefined();
479
+
480
+ consoleSpy.mockRestore();
481
+ });
482
+
483
+ it("logs plugin response transformation", async () => {
484
+ const mock = schmock({ debug: true });
485
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
486
+
487
+ const plugin = {
488
+ name: "transformer-plugin",
489
+ process: (ctx: any, res: any) => ({
490
+ context: ctx,
491
+ response: `transformed-${res}`,
492
+ }),
493
+ };
494
+
495
+ mock("GET /test", "original").pipe(plugin);
496
+
497
+ await mock.handle("GET", "/test");
498
+
499
+ // Check that plugin response transformation was logged
500
+ const transformedCall = consoleSpy.mock.calls.find(
501
+ (call) =>
502
+ call[0].includes("[SCHMOCK:PIPELINE]") &&
503
+ call[0].includes("Plugin transformer-plugin transformed response"),
504
+ );
505
+
506
+ expect(transformedCall).toBeDefined();
507
+
508
+ consoleSpy.mockRestore();
509
+ });
510
+ });
511
+ });