@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
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ class X extends Error{code;context;constructor(A,B,G){super(A);this.code=B;this.context=G;this.name="SchmockError",Error.captureStackTrace(this,this.constructor)}}class _ extends X{constructor(A,B){super(`Route not found: ${A} ${B}`,"ROUTE_NOT_FOUND",{method:A,path:B});this.name="RouteNotFoundError"}}class $ extends X{constructor(A,B){super(`Invalid route key format: "${A}". ${B}`,"ROUTE_PARSE_ERROR",{routeKey:A,reason:B});this.name="RouteParseError"}}class z extends X{constructor(A,B){super(`Failed to generate response for route ${A}: ${B.message}`,"RESPONSE_GENERATION_ERROR",{route:A,originalError:B});this.name="ResponseGenerationError"}}class j extends X{constructor(A,B){super(`Plugin "${A}" failed: ${B.message}`,"PLUGIN_ERROR",{pluginName:A,originalError:B});this.name="PluginError"}}class O extends X{constructor(A,B){super(`Invalid route definition for "${A}": ${B}`,"ROUTE_DEFINITION_ERROR",{routeKey:A,reason:B});this.name="RouteDefinitionError"}}class M extends X{constructor(A,B,G){super(`Schema validation failed at ${A}: ${B}${G?`. ${G}`:""}`,"SCHEMA_VALIDATION_ERROR",{schemaPath:A,issue:B,suggestion:G});this.name="SchemaValidationError"}}class N extends X{constructor(A,B,G){super(`Schema generation failed for route ${A}: ${B.message}`,"SCHEMA_GENERATION_ERROR",{route:A,originalError:B,schema:G});this.name="SchemaGenerationError"}}class K extends X{constructor(A,B,G){super(`Resource limit exceeded for ${A}: limit=${B}${G?`, actual=${G}`:""}`,"RESOURCE_LIMIT_ERROR",{resource:A,limit:B,actual:G});this.name="ResourceLimitError"}}function S(A){let B=A.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) (.+)$/);if(!B)throw new $(A,'Expected format: "METHOD /path" (e.g., "GET /users")');let[,G,H]=B,J=[],Q=/:([^/]+)/g,T;T=Q.exec(H);while(T!==null)J.push(T[1]),T=Q.exec(H);let U=H.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/:([^/]+)/g,"([^/]+)"),V=new RegExp(`^${U}$`);return{method:G,path:H,pattern:V,params:J}}class k{enabled;constructor(A=!1){this.enabled=A}log(A,B,G){if(!this.enabled)return;let J=`[${new Date().toISOString()}] [SCHMOCK:${A.toUpperCase()}]`;if(G)console.log(`${J} ${B}`,G);else console.log(`${J} ${B}`)}time(A){if(!this.enabled)return;console.time(`[SCHMOCK] ${A}`)}timeEnd(A){if(!this.enabled)return;console.timeEnd(`[SCHMOCK] ${A}`)}}class L{globalConfig;routes=[];plugins=[];logger;constructor(A={}){this.globalConfig=A;if(this.logger=new k(A.debug||!1),A.debug)this.logger.log("config","Debug mode enabled");this.logger.log("config","Callable mock instance created",{debug:A.debug,namespace:A.namespace,delay:A.delay})}defineRoute(A,B,G){if(!G.contentType)if(typeof B==="function")G.contentType="application/json";else if(typeof B==="string"||typeof B==="number"||typeof B==="boolean")G.contentType="text/plain";else if(Buffer.isBuffer(B))G.contentType="application/octet-stream";else G.contentType="application/json";if(typeof B!=="function"&&G.contentType==="application/json")try{JSON.stringify(B)}catch(Q){throw new O(A,"Generator data is not valid JSON but contentType is application/json")}let H=S(A),J={pattern:H.pattern,params:H.params,method:H.method,path:H.path,generator:B,config:G};return this.routes.push(J),this.logger.log("route",`Route defined: ${A}`,{contentType:G.contentType,generatorType:typeof B,hasParams:H.params.length>0}),this}pipe(A){return this.plugins.push(A),this.logger.log("plugin",`Registered plugin: ${A.name}@${A.version||"unknown"}`,{name:A.name,version:A.version,hasProcess:typeof A.process==="function",hasOnError:typeof A.onError==="function"}),this}async handle(A,B,G){let H=Math.random().toString(36).substring(7);this.logger.log("request",`[${H}] ${A} ${B}`,{headers:G?.headers,query:G?.query,bodyType:G?.body?typeof G.body:"none"}),this.logger.time(`request-${H}`);try{let J=B;if(this.globalConfig.namespace){let W=this.globalConfig.namespace;if(W==="/")J=B;else{let Y=W.startsWith("/")?W:`/${W}`,w=B.startsWith("/")?B:`/${B}`,F=Y.endsWith("/")&&Y!=="/"?Y.slice(0,-1):Y;if(!w.startsWith(F)){this.logger.log("route",`[${H}] Path doesn't match namespace ${W}`);let v=new _(A,B),I={status:404,body:{error:v.message,code:v.code},headers:{}};return this.logger.timeEnd(`request-${H}`),I}if(J=w.substring(F.length),!J.startsWith("/"))J=`/${J}`}}let Q=this.findRoute(A,J);if(!Q){this.logger.log("route",`[${H}] No route found for ${A} ${J}`);let W=new _(A,B),Y={status:404,body:{error:W.message,code:W.code},headers:{}};return this.logger.timeEnd(`request-${H}`),Y}this.logger.log("route",`[${H}] Matched route: ${A} ${Q.path}`);let T=this.extractParams(Q,J),U={method:A,path:J,params:T,query:G?.query||{},headers:G?.headers||{},body:G?.body,state:this.globalConfig.state||{}},V;if(typeof Q.generator==="function")V=await Q.generator(U);else V=Q.generator;let D={path:J,route:Q.config,method:A,params:T,query:G?.query||{},headers:G?.headers||{},body:G?.body,state:new Map,routeState:this.globalConfig.state||{}};try{let W=await this.runPluginPipeline(D,V,Q.config,H);D=W.context,V=W.response}catch(W){throw this.logger.log("error",`[${H}] Plugin pipeline error: ${W.message}`),W}let Z=this.parseResponse(V,Q.config);return await this.applyDelay(),this.logger.log("response",`[${H}] Sending response ${Z.status}`,{status:Z.status,headers:Z.headers,bodyType:typeof Z.body}),this.logger.timeEnd(`request-${H}`),Z}catch(J){this.logger.log("error",`[${H}] Error processing request: ${J.message}`,J);let Q={status:500,body:{error:J.message,code:J instanceof X?J.code:"INTERNAL_ERROR"},headers:{}};return await this.applyDelay(),this.logger.log("error",`[${H}] Returning error response 500`),this.logger.timeEnd(`request-${H}`),Q}}async applyDelay(){if(!this.globalConfig.delay)return;let A=Array.isArray(this.globalConfig.delay)?Math.random()*(this.globalConfig.delay[1]-this.globalConfig.delay[0])+this.globalConfig.delay[0]:this.globalConfig.delay;await new Promise((B)=>setTimeout(B,A))}parseResponse(A,B){let G=200,H=A,J={},Q=!1;if(A&&typeof A==="object"&&"status"in A&&"body"in A)return{status:A.status,body:A.body,headers:A.headers||{}};if(Array.isArray(A)&&typeof A[0]==="number")[G,H,J={}]=A,Q=!0;if(H===null||H===void 0){if(!Q)G=G===200?204:G;H=void 0}if(!J["content-type"]&&B.contentType&&!Q){if(J["content-type"]=B.contentType,B.contentType==="text/plain"&&H!==void 0){if(typeof H==="object"&&!Buffer.isBuffer(H))H=JSON.stringify(H);else if(typeof H!=="string")H=String(H)}}return{status:G,body:H,headers:J}}async runPluginPipeline(A,B,G,H){let J=A,Q=B;this.logger.log("pipeline",`Running plugin pipeline for ${this.plugins.length} plugins`);for(let T of this.plugins){this.logger.log("pipeline",`Processing plugin: ${T.name}`);try{let U=await T.process(J,Q);if(!U||!U.context)throw Error(`Plugin ${T.name} didn't return valid result`);if(J=U.context,U.response!==void 0&&(Q===void 0||Q===null))this.logger.log("pipeline",`Plugin ${T.name} generated response`),Q=U.response;else if(U.response!==void 0&&Q!==void 0)this.logger.log("pipeline",`Plugin ${T.name} transformed response`),Q=U.response}catch(U){if(this.logger.log("pipeline",`Plugin ${T.name} failed: ${U.message}`),T.onError)try{let V=await T.onError(U,J);if(V){if(this.logger.log("pipeline",`Plugin ${T.name} handled error`),typeof V==="object"&&V.status){Q=V;break}}}catch(V){this.logger.log("pipeline",`Plugin ${T.name} error handler failed: ${V.message}`)}throw new j(T.name,U)}}return{context:J,response:Q}}findRoute(A,B){for(let G=this.routes.length-1;G>=0;G--){let H=this.routes[G];if(H.method===A&&H.params.length===0&&H.pattern.test(B))return H}for(let G=this.routes.length-1;G>=0;G--){let H=this.routes[G];if(H.method===A&&H.params.length>0&&H.pattern.test(B))return H}return}extractParams(A,B){let G=B.match(A.pattern);if(!G)return{};let H={};return A.params.forEach((J,Q)=>{H[J]=G[Q+1]}),H}}function b(A){let B=new L(A||{}),G=(H,J,Q={})=>{return B.defineRoute(H,J,Q),G};return G.pipe=(H)=>{return B.pipe(H),G},G.handle=B.handle.bind(B),G}export{b as schmock,X as SchmockError,M as SchemaValidationError,N as SchemaGenerationError,$ as RouteParseError,_ as RouteNotFoundError,O as RouteDefinitionError,z as ResponseGenerationError,K as ResourceLimitError,j as PluginError};
@@ -0,0 +1,19 @@
1
+ import type { HttpMethod } from "./types";
2
+ export interface ParsedRoute {
3
+ method: HttpMethod;
4
+ path: string;
5
+ pattern: RegExp;
6
+ params: string[];
7
+ }
8
+ /**
9
+ * Parse 'METHOD /path' route key format
10
+ *
11
+ * Design note: We validate the format strictly to catch typos early.
12
+ * The 'METHOD /path' format was chosen for its readability and
13
+ * similarity to API documentation formats.
14
+ *
15
+ * @example
16
+ * parseRouteKey('GET /users/:id')
17
+ * // => { method: 'GET', path: '/users/:id', pattern: /^\/users\/([^/]+)$/, params: ['id'] }
18
+ */
19
+ export declare function parseRouteKey(routeKey: string): ParsedRoute;
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/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"}
package/dist/parser.js ADDED
@@ -0,0 +1,40 @@
1
+ import { RouteParseError } from "./errors";
2
+ /**
3
+ * Parse 'METHOD /path' route key format
4
+ *
5
+ * Design note: We validate the format strictly to catch typos early.
6
+ * The 'METHOD /path' format was chosen for its readability and
7
+ * similarity to API documentation formats.
8
+ *
9
+ * @example
10
+ * parseRouteKey('GET /users/:id')
11
+ * // => { method: 'GET', path: '/users/:id', pattern: /^\/users\/([^/]+)$/, params: ['id'] }
12
+ */
13
+ export function parseRouteKey(routeKey) {
14
+ const match = routeKey.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) (.+)$/);
15
+ if (!match) {
16
+ throw new RouteParseError(routeKey, 'Expected format: "METHOD /path" (e.g., "GET /users")');
17
+ }
18
+ const [, method, path] = match;
19
+ // Extract parameter names
20
+ const params = [];
21
+ const paramPattern = /:([^/]+)/g;
22
+ let paramMatch;
23
+ paramMatch = paramPattern.exec(path);
24
+ while (paramMatch !== null) {
25
+ params.push(paramMatch[1]);
26
+ paramMatch = paramPattern.exec(path);
27
+ }
28
+ // Build regex pattern for matching
29
+ // Replace :param with capture groups
30
+ const regexPath = path
31
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars except :
32
+ .replace(/:([^/]+)/g, "([^/]+)"); // Replace :param with capture group
33
+ const pattern = new RegExp(`^${regexPath}$`);
34
+ return {
35
+ method: method,
36
+ path,
37
+ pattern,
38
+ params,
39
+ };
40
+ }
@@ -0,0 +1,15 @@
1
+ export type HttpMethod = Schmock.HttpMethod;
2
+ export type RouteKey = Schmock.RouteKey;
3
+ export type ResponseResult = Schmock.ResponseResult;
4
+ export type RequestContext = Schmock.RequestContext;
5
+ export type Response = Schmock.Response;
6
+ export type RequestOptions = Schmock.RequestOptions;
7
+ export type GlobalConfig = Schmock.GlobalConfig;
8
+ export type RouteConfig = Schmock.RouteConfig;
9
+ export type Generator = Schmock.Generator;
10
+ export type GeneratorFunction = Schmock.GeneratorFunction;
11
+ export type CallableMockInstance = Schmock.CallableMockInstance;
12
+ export type Plugin = Schmock.Plugin;
13
+ export type PluginContext = Schmock.PluginContext;
14
+ export type PluginResult = Schmock.PluginResult;
15
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;AAC9C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;AAC1D,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;AAChE,MAAM,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AACpC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /// <reference path="../../../types/schmock.d.ts" />
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@schmock/core",
3
+ "description": "Core functionality for Schmock",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "src"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "scripts": {
21
+ "build": "bun build:lib && bun build:types",
22
+ "build:lib": "bun build --minify --outdir=dist src/index.ts",
23
+ "build:types": "tsc -p tsconfig.json",
24
+ "pretest": "rm -f src/*.js src/*.d.ts || true",
25
+ "test": "vitest",
26
+ "test:watch": "vitest --watch",
27
+ "pretest:bdd": "rm -f src/*.js src/*.d.ts || true",
28
+ "test:bdd": "vitest run --config vitest.config.bdd.ts",
29
+ "lint": "biome check src/*.ts",
30
+ "lint:fix": "biome check --write --unsafe src/*.ts"
31
+ },
32
+ "license": "MIT",
33
+ "devDependencies": {
34
+ "@amiceli/vitest-cucumber": "^6.1.0",
35
+ "@types/node": "^24.9.1",
36
+ "@vitest/ui": "^4.0.15",
37
+ "vitest": "^4.0.15"
38
+ }
39
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["builder.ts"],"names":[],"mappings":"AAAA,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AAW7B,OAAO,KAAK,EACV,OAAO,EACP,aAAa,EAEb,YAAY,EAGZ,MAAM,EACP,MAAM,SAAS,CAAC;AAajB;;;;GAIG;AACH,qBAAa,cAAc,CAAC,MAAM,GAAG,OAAO,CAAE,YAAW,OAAO,CAAC,MAAM,CAAC;IACtE,OAAO,CAAC,OAAO,CAEb;IAEF,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,KAAK,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAShC,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAKpC,KAAK,IAAI,YAAY,CAAC,MAAM,CAAC;IAQ7B;;;OAGG;IACH,OAAO,CAAC,aAAa;CA0BtB"}
@@ -0,0 +1,289 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { schmock } from "./index";
3
+
4
+ describe("schmock callable API", () => {
5
+ describe("callable instance", () => {
6
+ it("returns callable mock instance from factory", () => {
7
+ const mock = schmock();
8
+ expect(mock).toBeDefined();
9
+ expect(typeof mock).toBe("function");
10
+ expect(mock.handle).toBeTypeOf("function");
11
+ expect(mock.pipe).toBeTypeOf("function");
12
+ });
13
+
14
+ it("allows defining routes through function calls", () => {
15
+ const mock = schmock();
16
+ const result = mock("GET /users", () => [], {});
17
+
18
+ expect(result).toBe(mock); // Should return same instance for chaining
19
+ expect(result.pipe).toBeTypeOf("function");
20
+ });
21
+
22
+ it("supports chaining with pipe method", () => {
23
+ const mock = schmock();
24
+ const result = mock("GET /users", () => [], {}).pipe({
25
+ name: "test-plugin",
26
+ process: (ctx, response) => ({ context: ctx, response }),
27
+ });
28
+
29
+ expect(result).toBe(mock); // Should return same instance for chaining
30
+ });
31
+ });
32
+
33
+ describe("request handling", () => {
34
+ it("handles simple GET request", async () => {
35
+ const mock = schmock();
36
+ mock("GET /users", () => [{ id: 1, name: "John" }], {});
37
+
38
+ const response = await mock.handle("GET", "/users");
39
+
40
+ expect(response.status).toBe(200);
41
+ expect(response.body).toEqual([{ id: 1, name: "John" }]);
42
+ expect(response.headers).toEqual({ "content-type": "application/json" });
43
+ });
44
+
45
+ it("returns 404 for undefined routes", async () => {
46
+ const mock = schmock();
47
+ mock("GET /users", () => [], {});
48
+
49
+ const response = await mock.handle("GET", "/posts");
50
+
51
+ expect(response.status).toBe(404);
52
+ });
53
+
54
+ it("handles route with parameters", async () => {
55
+ const mock = schmock();
56
+ mock("GET /users/:id", ({ params }) => ({ userId: params.id }), {});
57
+
58
+ const response = await mock.handle("GET", "/users/123");
59
+
60
+ expect(response.status).toBe(200);
61
+ expect(response.body).toEqual({ userId: "123" });
62
+ });
63
+
64
+ it("handles custom status codes via tuple", async () => {
65
+ const mock = schmock();
66
+ mock("POST /users", ({ body }) => [201, { id: 1, ...body }], {});
67
+
68
+ const response = await mock.handle("POST", "/users", {
69
+ body: { name: "Alice" },
70
+ });
71
+
72
+ expect(response.status).toBe(201);
73
+ expect(response.body).toEqual({ id: 1, name: "Alice" });
74
+ });
75
+
76
+ it("handles custom headers via triple tuple", async () => {
77
+ const mock = schmock();
78
+ mock(
79
+ "POST /users",
80
+ ({ body }) => [201, { id: 1, ...body }, { Location: "/users/1" }],
81
+ {},
82
+ );
83
+
84
+ const response = await mock.handle("POST", "/users", {
85
+ body: { name: "Alice" },
86
+ });
87
+
88
+ expect(response.status).toBe(201);
89
+ expect(response.headers).toEqual({
90
+ Location: "/users/1",
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("state management", () => {
96
+ it("maintains state across requests with global config", async () => {
97
+ const globalState = { count: 0 };
98
+ const mock = schmock({ state: globalState });
99
+ mock(
100
+ "GET /increment",
101
+ ({ state }) => {
102
+ state.count++;
103
+ return { value: state.count };
104
+ },
105
+ {},
106
+ );
107
+
108
+ const first = await mock.handle("GET", "/increment");
109
+ const second = await mock.handle("GET", "/increment");
110
+
111
+ expect(first.body).toEqual({ value: 1 });
112
+ expect(second.body).toEqual({ value: 2 });
113
+ });
114
+
115
+ it("shares state between routes with global config", async () => {
116
+ const globalState = { users: [] as unknown[] };
117
+ const mock = schmock({ state: globalState });
118
+
119
+ mock(
120
+ "POST /users",
121
+ ({ body, state }) => {
122
+ const user = { id: Date.now(), ...body };
123
+ state.users.push(user);
124
+ return [201, user];
125
+ },
126
+ {},
127
+ );
128
+
129
+ mock("GET /users", ({ state }) => state.users, {});
130
+
131
+ await mock.handle("POST", "/users", { body: { name: "John" } });
132
+ const response = await mock.handle("GET", "/users");
133
+
134
+ expect(response.body).toHaveLength(1);
135
+ expect(response.body[0]).toHaveProperty("name", "John");
136
+ });
137
+ });
138
+
139
+ describe("configuration", () => {
140
+ it("applies namespace to all routes", async () => {
141
+ const mock = schmock({ namespace: "/api/v1" });
142
+ mock("GET /users", () => [], {});
143
+
144
+ const response = await mock.handle("GET", "/api/v1/users");
145
+
146
+ expect(response.status).toBe(200);
147
+ expect(response.body).toEqual([]);
148
+ });
149
+
150
+ it("returns 404 without namespace prefix", async () => {
151
+ const mock = schmock({ namespace: "/api/v1" });
152
+ mock("GET /users", () => [], {});
153
+
154
+ const response = await mock.handle("GET", "/users");
155
+
156
+ expect(response.status).toBe(404);
157
+ });
158
+ });
159
+
160
+ describe("request context", () => {
161
+ it("provides query parameters", async () => {
162
+ const mock = schmock();
163
+ mock(
164
+ "GET /search",
165
+ ({ query }) => ({
166
+ results: [],
167
+ query: query.q,
168
+ }),
169
+ {},
170
+ );
171
+
172
+ const response = await mock.handle("GET", "/search", {
173
+ query: { q: "test" },
174
+ });
175
+
176
+ expect(response.body).toEqual({
177
+ results: [],
178
+ query: "test",
179
+ });
180
+ });
181
+
182
+ it("provides headers", async () => {
183
+ const mock = schmock();
184
+ mock(
185
+ "GET /auth",
186
+ ({ headers }) => ({
187
+ authenticated: headers.authorization === "Bearer token123",
188
+ }),
189
+ {},
190
+ );
191
+
192
+ const response = await mock.handle("GET", "/auth", {
193
+ headers: { authorization: "Bearer token123" },
194
+ });
195
+
196
+ expect(response.body).toEqual({ authenticated: true });
197
+ });
198
+
199
+ it("provides method and path in context", async () => {
200
+ const mock = schmock();
201
+ mock("GET /info", ({ method, path }) => ({ method, path }), {});
202
+
203
+ const response = await mock.handle("GET", "/info");
204
+
205
+ expect(response.body).toEqual({
206
+ method: "GET",
207
+ path: "/info",
208
+ });
209
+ });
210
+ });
211
+
212
+ describe("contentType auto-detection", () => {
213
+ it("defaults to application/json for function generators", async () => {
214
+ const mock = schmock();
215
+ mock("GET /users", () => [{ id: 1 }], {});
216
+
217
+ const response = await mock.handle("GET", "/users");
218
+ expect(response.status).toBe(200);
219
+ expect(response.body).toEqual([{ id: 1 }]);
220
+ });
221
+
222
+ it("defaults to text/plain for string generators", async () => {
223
+ const mock = schmock();
224
+ mock("GET /text", "Hello World", {});
225
+
226
+ const response = await mock.handle("GET", "/text");
227
+ expect(response.status).toBe(200);
228
+ expect(response.body).toBe("Hello World");
229
+ });
230
+
231
+ it("defaults to application/json for object generators", async () => {
232
+ const mock = schmock();
233
+ mock("GET /users", [{ id: 1, name: "John" }], {});
234
+
235
+ const response = await mock.handle("GET", "/users");
236
+ expect(response.status).toBe(200);
237
+ expect(response.body).toEqual([{ id: 1, name: "John" }]);
238
+ });
239
+
240
+ it("allows explicit contentType override", async () => {
241
+ const mock = schmock();
242
+ mock("GET /json", "Hello World", { contentType: "application/json" });
243
+
244
+ const response = await mock.handle("GET", "/json");
245
+ expect(response.status).toBe(200);
246
+ expect(response.body).toBe("Hello World");
247
+ });
248
+
249
+ it("validates JSON data for application/json contentType", () => {
250
+ const mock = schmock();
251
+
252
+ // This should work - valid JSON data
253
+ expect(() => {
254
+ mock("GET /users", [{ id: 1 }], { contentType: "application/json" });
255
+ }).not.toThrow();
256
+
257
+ // This should fail - circular reference can't be JSON serialized
258
+ const circular: any = {};
259
+ circular.self = circular;
260
+
261
+ expect(() => {
262
+ mock("GET /users", circular, { contentType: "application/json" });
263
+ }).toThrow(
264
+ "Generator data is not valid JSON but contentType is application/json",
265
+ );
266
+ });
267
+ });
268
+
269
+ describe("plugin integration", () => {
270
+ it("supports plugin pipeline with pipe method", async () => {
271
+ const mock = schmock();
272
+ mock("GET /users", [{ id: 1, name: "John" }], {}).pipe({
273
+ name: "test-plugin",
274
+ process: (ctx, response) => ({
275
+ context: ctx,
276
+ response: { data: response, processed: true },
277
+ }),
278
+ });
279
+
280
+ const response = await mock.handle("GET", "/users");
281
+
282
+ expect(response.status).toBe(200);
283
+ expect(response.body).toEqual({
284
+ data: [{ id: 1, name: "John" }],
285
+ processed: true,
286
+ });
287
+ });
288
+ });
289
+ });