@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,273 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { schmock } from "./index";
3
+
4
+ describe("namespace functionality", () => {
5
+ describe("basic namespace behavior", () => {
6
+ it("applies namespace to all routes", async () => {
7
+ const mock = schmock({ namespace: "/api" });
8
+ mock("GET /users", "users");
9
+ mock("POST /users", "create-user");
10
+
11
+ const getResponse = await mock.handle("GET", "/api/users");
12
+ const postResponse = await mock.handle("POST", "/api/users");
13
+
14
+ expect(getResponse.body).toBe("users");
15
+ expect(postResponse.body).toBe("create-user");
16
+ });
17
+
18
+ it("returns 404 for requests without namespace prefix", async () => {
19
+ const mock = schmock({ namespace: "/api" });
20
+ mock("GET /users", "users");
21
+
22
+ const response = await mock.handle("GET", "/users");
23
+
24
+ expect(response.status).toBe(404);
25
+ expect(response.body.error).toContain("Route not found: GET /users");
26
+ });
27
+
28
+ it("handles nested namespaces", async () => {
29
+ const mock = schmock({ namespace: "/api/v1" });
30
+ mock("GET /users", "v1-users");
31
+
32
+ const response = await mock.handle("GET", "/api/v1/users");
33
+
34
+ expect(response.body).toBe("v1-users");
35
+ });
36
+
37
+ it("works with root namespace", async () => {
38
+ const mock = schmock({ namespace: "/" });
39
+ mock("GET /users", "users");
40
+
41
+ const response = await mock.handle("GET", "/users");
42
+
43
+ expect(response.body).toBe("users");
44
+ });
45
+ });
46
+
47
+ describe("namespace with parameters", () => {
48
+ it("works with parameterized routes under namespace", async () => {
49
+ const mock = schmock({ namespace: "/api" });
50
+ mock("GET /users/:id", ({ params }) => ({ userId: params.id }));
51
+
52
+ const response = await mock.handle("GET", "/api/users/123");
53
+
54
+ expect(response.body).toEqual({ userId: "123" });
55
+ });
56
+
57
+ it("extracts parameters correctly after namespace removal", async () => {
58
+ const mock = schmock({ namespace: "/api/v1" });
59
+ mock("GET /users/:userId/posts/:postId", ({ params, path }) => ({
60
+ params,
61
+ processedPath: path,
62
+ }));
63
+
64
+ const response = await mock.handle("GET", "/api/v1/users/456/posts/789");
65
+
66
+ expect(response.body).toEqual({
67
+ params: { userId: "456", postId: "789" },
68
+ processedPath: "/users/456/posts/789",
69
+ });
70
+ });
71
+
72
+ it("passes correct path to context after namespace stripping", async () => {
73
+ const mock = schmock({ namespace: "/api" });
74
+ mock("GET /test/:param", ({ path, params }) => ({
75
+ contextPath: path,
76
+ params,
77
+ }));
78
+
79
+ const response = await mock.handle("GET", "/api/test/value");
80
+
81
+ expect(response.body).toEqual({
82
+ contextPath: "/test/value",
83
+ params: { param: "value" },
84
+ });
85
+ });
86
+ });
87
+
88
+ describe("namespace edge cases", () => {
89
+ it("handles namespace with trailing slash", async () => {
90
+ const mock = schmock({ namespace: "/api/" });
91
+ mock("GET /users", "users");
92
+
93
+ // Should work with or without the trailing slash in the request
94
+ const response1 = await mock.handle("GET", "/api/users");
95
+ const response2 = await mock.handle("GET", "/api//users");
96
+
97
+ expect(response1.body).toBe("users");
98
+ // This might not match depending on implementation
99
+ expect(response2.status).toBe(404);
100
+ });
101
+
102
+ it("handles empty namespace", async () => {
103
+ const mock = schmock({ namespace: "" });
104
+ mock("GET /users", "users");
105
+
106
+ const response = await mock.handle("GET", "/users");
107
+
108
+ expect(response.body).toBe("users");
109
+ });
110
+
111
+ it("handles namespace without leading slash", async () => {
112
+ const mock = schmock({ namespace: "api" });
113
+ mock("GET /users", "users");
114
+
115
+ const response = await mock.handle("GET", "api/users");
116
+
117
+ expect(response.body).toBe("users");
118
+ });
119
+
120
+ it("rejects requests that partially match namespace", async () => {
121
+ const mock = schmock({ namespace: "/api" });
122
+ mock("GET /users", "users");
123
+
124
+ const response1 = await mock.handle("GET", "/ap/users");
125
+ const response2 = await mock.handle("GET", "/apiextra/users");
126
+
127
+ expect(response1.status).toBe(404);
128
+ expect(response2.status).toBe(404);
129
+ });
130
+
131
+ it("handles very long namespaces", async () => {
132
+ const longNamespace =
133
+ "/api/v1/internal/microservice/health/monitoring/endpoints";
134
+ const mock = schmock({ namespace: longNamespace });
135
+ mock("GET /status", "healthy");
136
+
137
+ const response = await mock.handle("GET", `${longNamespace}/status`);
138
+
139
+ expect(response.body).toBe("healthy");
140
+ });
141
+ });
142
+
143
+ describe("namespace without routes", () => {
144
+ it("returns 404 when no routes defined", async () => {
145
+ const mock = schmock({ namespace: "/api" });
146
+
147
+ const response = await mock.handle("GET", "/api/anything");
148
+
149
+ expect(response.status).toBe(404);
150
+ });
151
+
152
+ it("returns 404 for namespace root when no root route", async () => {
153
+ const mock = schmock({ namespace: "/api" });
154
+ mock("GET /users", "users");
155
+
156
+ const response = await mock.handle("GET", "/api");
157
+
158
+ expect(response.status).toBe(404);
159
+ });
160
+
161
+ it("supports root route under namespace", async () => {
162
+ const mock = schmock({ namespace: "/api" });
163
+ mock("GET /", "api-root");
164
+
165
+ const response = await mock.handle("GET", "/api/");
166
+
167
+ expect(response.body).toBe("api-root");
168
+ });
169
+ });
170
+
171
+ describe("namespace with global state", () => {
172
+ it("maintains global state across namespaced routes", async () => {
173
+ const mock = schmock({
174
+ namespace: "/api",
175
+ state: { counter: 0 },
176
+ });
177
+
178
+ mock("POST /increment", ({ state }) => {
179
+ state.counter++;
180
+ return { counter: state.counter };
181
+ });
182
+
183
+ mock("GET /count", ({ state }) => ({ counter: state.counter }));
184
+
185
+ await mock.handle("POST", "/api/increment");
186
+ await mock.handle("POST", "/api/increment");
187
+ const response = await mock.handle("GET", "/api/count");
188
+
189
+ expect(response.body).toEqual({ counter: 2 });
190
+ });
191
+ });
192
+
193
+ describe("namespace with plugins", () => {
194
+ it("works correctly with plugin pipeline", async () => {
195
+ const mock = schmock({ namespace: "/api" });
196
+
197
+ const plugin = {
198
+ name: "namespace-plugin",
199
+ process: (ctx: any, res: any) => {
200
+ return {
201
+ context: ctx,
202
+ response: {
203
+ ...res,
204
+ namespacedPath: ctx.path,
205
+ },
206
+ };
207
+ },
208
+ };
209
+
210
+ mock("GET /users", { users: [] }).pipe(plugin);
211
+
212
+ const response = await mock.handle("GET", "/api/users");
213
+
214
+ expect(response.body).toEqual({
215
+ users: [],
216
+ namespacedPath: "/users", // Should be the path after namespace removal
217
+ });
218
+ });
219
+ });
220
+
221
+ describe("special characters in namespace", () => {
222
+ it("handles namespace with special characters", async () => {
223
+ const mock = schmock({ namespace: "/api-v1.2" });
224
+ mock("GET /users", "users");
225
+
226
+ const response = await mock.handle("GET", "/api-v1.2/users");
227
+
228
+ expect(response.body).toBe("users");
229
+ });
230
+
231
+ it("handles namespace with underscores", async () => {
232
+ const mock = schmock({ namespace: "/my_api" });
233
+ mock("GET /test", "test");
234
+
235
+ const response = await mock.handle("GET", "/my_api/test");
236
+
237
+ expect(response.body).toBe("test");
238
+ });
239
+
240
+ it("handles namespace with numbers", async () => {
241
+ const mock = schmock({ namespace: "/api2" });
242
+ mock("GET /version", "v2");
243
+
244
+ const response = await mock.handle("GET", "/api2/version");
245
+
246
+ expect(response.body).toBe("v2");
247
+ });
248
+ });
249
+
250
+ describe("namespace error messages", () => {
251
+ it("provides clear error message for namespace mismatch", async () => {
252
+ const mock = schmock({ namespace: "/api/v1" });
253
+ mock("GET /users", "users");
254
+
255
+ const response = await mock.handle("GET", "/api/v2/users");
256
+
257
+ expect(response.status).toBe(404);
258
+ expect(response.body.error).toContain(
259
+ "Route not found: GET /api/v2/users",
260
+ );
261
+ expect(response.body.code).toBe("ROUTE_NOT_FOUND");
262
+ });
263
+
264
+ it("includes original requested path in error", async () => {
265
+ const mock = schmock({ namespace: "/api" });
266
+ mock("GET /users", "users");
267
+
268
+ const response = await mock.handle("GET", "/wrong/path");
269
+
270
+ expect(response.body.error).toContain("/wrong/path");
271
+ });
272
+ });
273
+ });
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["parser.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAuC3D"}
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseRouteKey } from "./parser";
3
+
4
+ describe("parseRouteKey", () => {
5
+ describe("valid route keys", () => {
6
+ it("parses simple GET route", () => {
7
+ const result = parseRouteKey("GET /users");
8
+ expect(result).toEqual({
9
+ method: "GET",
10
+ path: "/users",
11
+ pattern: expect.any(RegExp),
12
+ params: [],
13
+ });
14
+ });
15
+
16
+ it("parses route with single parameter", () => {
17
+ const result = parseRouteKey("GET /users/:id");
18
+ expect(result).toEqual({
19
+ method: "GET",
20
+ path: "/users/:id",
21
+ pattern: expect.any(RegExp),
22
+ params: ["id"],
23
+ });
24
+ });
25
+
26
+ it("parses route with multiple parameters", () => {
27
+ const result = parseRouteKey(
28
+ "DELETE /api/posts/:postId/comments/:commentId",
29
+ );
30
+ expect(result).toEqual({
31
+ method: "DELETE",
32
+ path: "/api/posts/:postId/comments/:commentId",
33
+ pattern: expect.any(RegExp),
34
+ params: ["postId", "commentId"],
35
+ });
36
+ });
37
+
38
+ it("supports all HTTP methods", () => {
39
+ const methods = [
40
+ "GET",
41
+ "POST",
42
+ "PUT",
43
+ "DELETE",
44
+ "PATCH",
45
+ "HEAD",
46
+ "OPTIONS",
47
+ ] as const;
48
+
49
+ for (const method of methods) {
50
+ const result = parseRouteKey(`${method} /test`);
51
+ expect(result.method).toBe(method);
52
+ }
53
+ });
54
+
55
+ it("handles paths with namespace", () => {
56
+ const result = parseRouteKey("POST /api/v2/users");
57
+ expect(result).toEqual({
58
+ method: "POST",
59
+ path: "/api/v2/users",
60
+ pattern: expect.any(RegExp),
61
+ params: [],
62
+ });
63
+ });
64
+
65
+ it("creates correct regex pattern for simple path", () => {
66
+ const result = parseRouteKey("GET /users");
67
+ expect("/users").toMatch(result.pattern);
68
+ expect("/users/123").not.toMatch(result.pattern);
69
+ });
70
+
71
+ it("creates correct regex pattern with parameters", () => {
72
+ const result = parseRouteKey("GET /users/:id");
73
+ expect("/users/123").toMatch(result.pattern);
74
+ expect("/users/abc-def").toMatch(result.pattern);
75
+ expect("/users").not.toMatch(result.pattern);
76
+ expect("/users/").not.toMatch(result.pattern);
77
+ expect("/users/123/posts").not.toMatch(result.pattern);
78
+ });
79
+ });
80
+
81
+ describe("invalid route keys", () => {
82
+ it("throws on missing method", () => {
83
+ expect(() => parseRouteKey("/users")).toThrow("Invalid route key format");
84
+ });
85
+
86
+ it("throws on missing path", () => {
87
+ expect(() => parseRouteKey("GET")).toThrow("Invalid route key format");
88
+ });
89
+
90
+ it("throws on invalid method", () => {
91
+ expect(() => parseRouteKey("INVALID /users")).toThrow(
92
+ "Invalid route key format",
93
+ );
94
+ });
95
+
96
+ it("throws on lowercase method", () => {
97
+ expect(() => parseRouteKey("get /users")).toThrow(
98
+ "Invalid route key format",
99
+ );
100
+ });
101
+
102
+ it("throws on missing space", () => {
103
+ expect(() => parseRouteKey("GET/users")).toThrow(
104
+ "Invalid route key format",
105
+ );
106
+ });
107
+
108
+ it("throws on empty string", () => {
109
+ expect(() => parseRouteKey("")).toThrow("Invalid route key format");
110
+ });
111
+ });
112
+
113
+ describe("parameter extraction", () => {
114
+ it("extracts matched parameters", () => {
115
+ const route = parseRouteKey("GET /users/:userId/posts/:postId");
116
+ const match = "/users/123/posts/456".match(route.pattern);
117
+
118
+ expect(match).toBeTruthy();
119
+ expect(match?.[1]).toBe("123");
120
+ expect(match?.[2]).toBe("456");
121
+ });
122
+
123
+ it("handles special characters in parameters", () => {
124
+ const route = parseRouteKey("GET /files/:filename");
125
+ const match = "/files/report-2023.pdf".match(route.pattern);
126
+
127
+ expect(match).toBeTruthy();
128
+ expect(match?.[1]).toBe("report-2023.pdf");
129
+ });
130
+ });
131
+ });
package/src/parser.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { RouteParseError } from "./errors";
2
+ import type { HttpMethod } from "./types";
3
+
4
+ export interface ParsedRoute {
5
+ method: HttpMethod;
6
+ path: string;
7
+ pattern: RegExp;
8
+ params: string[];
9
+ }
10
+
11
+ /**
12
+ * Parse 'METHOD /path' route key format
13
+ *
14
+ * Design note: We validate the format strictly to catch typos early.
15
+ * The 'METHOD /path' format was chosen for its readability and
16
+ * similarity to API documentation formats.
17
+ *
18
+ * @example
19
+ * parseRouteKey('GET /users/:id')
20
+ * // => { method: 'GET', path: '/users/:id', pattern: /^\/users\/([^/]+)$/, params: ['id'] }
21
+ */
22
+ export function parseRouteKey(routeKey: string): ParsedRoute {
23
+ const match = routeKey.match(
24
+ /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) (.+)$/,
25
+ );
26
+
27
+ if (!match) {
28
+ throw new RouteParseError(
29
+ routeKey,
30
+ 'Expected format: "METHOD /path" (e.g., "GET /users")',
31
+ );
32
+ }
33
+
34
+ const [, method, path] = match;
35
+
36
+ // Extract parameter names
37
+ const params: string[] = [];
38
+ const paramPattern = /:([^/]+)/g;
39
+ let paramMatch: RegExpExecArray | null;
40
+
41
+ paramMatch = paramPattern.exec(path);
42
+ while (paramMatch !== null) {
43
+ params.push(paramMatch[1]);
44
+ paramMatch = paramPattern.exec(path);
45
+ }
46
+
47
+ // Build regex pattern for matching
48
+ // Replace :param with capture groups
49
+ const regexPath = path
50
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars except :
51
+ .replace(/:([^/]+)/g, "([^/]+)"); // Replace :param with capture group
52
+
53
+ const pattern = new RegExp(`^${regexPath}$`);
54
+
55
+ return {
56
+ method: method as HttpMethod,
57
+ path,
58
+ pattern,
59
+ params,
60
+ };
61
+ }