@schmock/core 1.13.0 → 2.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.
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ badRequest,
4
+ created,
5
+ forbidden,
6
+ noContent,
7
+ notFound,
8
+ paginate,
9
+ serverError,
10
+ unauthorized,
11
+ } from "./helpers.js";
12
+
13
+ describe("notFound", () => {
14
+ it("returns 404 with default message", () => {
15
+ expect(notFound()).toEqual([404, { message: "Not Found" }]);
16
+ });
17
+ it("returns 404 with custom string message", () => {
18
+ expect(notFound("User not found")).toEqual([
19
+ 404,
20
+ { message: "User not found" },
21
+ ]);
22
+ });
23
+ it("returns 404 with custom object", () => {
24
+ expect(notFound({ code: "NOT_FOUND", detail: "gone" })).toEqual([
25
+ 404,
26
+ { code: "NOT_FOUND", detail: "gone" },
27
+ ]);
28
+ });
29
+ });
30
+
31
+ describe("badRequest", () => {
32
+ it("returns 400 with default message", () => {
33
+ expect(badRequest()).toEqual([400, { message: "Bad Request" }]);
34
+ });
35
+ it("returns 400 with custom string", () => {
36
+ expect(badRequest("Invalid email")).toEqual([
37
+ 400,
38
+ { message: "Invalid email" },
39
+ ]);
40
+ });
41
+ });
42
+
43
+ describe("unauthorized", () => {
44
+ it("returns 401 with default message", () => {
45
+ expect(unauthorized()).toEqual([401, { message: "Unauthorized" }]);
46
+ });
47
+ });
48
+
49
+ describe("forbidden", () => {
50
+ it("returns 403 with default message", () => {
51
+ expect(forbidden()).toEqual([403, { message: "Forbidden" }]);
52
+ });
53
+ });
54
+
55
+ describe("serverError", () => {
56
+ it("returns 500 with default message", () => {
57
+ expect(serverError()).toEqual([500, { message: "Internal Server Error" }]);
58
+ });
59
+ });
60
+
61
+ describe("created", () => {
62
+ it("returns 201 with body", () => {
63
+ expect(created({ id: 1, name: "John" })).toEqual([
64
+ 201,
65
+ { id: 1, name: "John" },
66
+ ]);
67
+ });
68
+ });
69
+
70
+ describe("noContent", () => {
71
+ it("returns 204 with null body", () => {
72
+ expect(noContent()).toEqual([204, null]);
73
+ });
74
+ });
75
+
76
+ describe("paginate", () => {
77
+ const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
78
+
79
+ it("returns first page with default pageSize", () => {
80
+ const result = paginate(items);
81
+ expect(result).toEqual({
82
+ data: items,
83
+ page: 1,
84
+ pageSize: 10,
85
+ total: 5,
86
+ totalPages: 1,
87
+ });
88
+ });
89
+
90
+ it("paginates correctly with custom options", () => {
91
+ const result = paginate(items, { page: 2, pageSize: 2 });
92
+ expect(result).toEqual({
93
+ data: [{ id: 3 }, { id: 4 }],
94
+ page: 2,
95
+ pageSize: 2,
96
+ total: 5,
97
+ totalPages: 3,
98
+ });
99
+ });
100
+
101
+ it("returns empty data for page beyond range", () => {
102
+ const result = paginate(items, { page: 10, pageSize: 2 });
103
+ expect(result.data).toEqual([]);
104
+ expect(result.total).toBe(5);
105
+ });
106
+ });
package/src/helpers.ts ADDED
@@ -0,0 +1,58 @@
1
+ /// <reference path="../schmock.d.ts" />
2
+
3
+ export function notFound(
4
+ message: string | object = "Not Found",
5
+ ): [number, object] {
6
+ const body = typeof message === "string" ? { message } : message;
7
+ return [404, body];
8
+ }
9
+
10
+ export function badRequest(
11
+ message: string | object = "Bad Request",
12
+ ): [number, object] {
13
+ const body = typeof message === "string" ? { message } : message;
14
+ return [400, body];
15
+ }
16
+
17
+ export function unauthorized(
18
+ message: string | object = "Unauthorized",
19
+ ): [number, object] {
20
+ const body = typeof message === "string" ? { message } : message;
21
+ return [401, body];
22
+ }
23
+
24
+ export function forbidden(
25
+ message: string | object = "Forbidden",
26
+ ): [number, object] {
27
+ const body = typeof message === "string" ? { message } : message;
28
+ return [403, body];
29
+ }
30
+
31
+ export function serverError(
32
+ message: string | object = "Internal Server Error",
33
+ ): [number, object] {
34
+ const body = typeof message === "string" ? { message } : message;
35
+ return [500, body];
36
+ }
37
+
38
+ export function created(body: object): [number, object] {
39
+ return [201, body];
40
+ }
41
+
42
+ export function noContent(): [number, null] {
43
+ return [204, null];
44
+ }
45
+
46
+ export function paginate<T>(
47
+ items: T[],
48
+ options: Schmock.PaginateOptions = {},
49
+ ): Schmock.PaginatedResponse<T> {
50
+ const page = options.page || 1;
51
+ const pageSize = options.pageSize || 10;
52
+ const total = items.length;
53
+ const totalPages = Math.ceil(total / pageSize);
54
+ const start = (page - 1) * pageSize;
55
+ const end = start + pageSize;
56
+ const data = items.slice(start, end);
57
+ return { data, page, pageSize, total, totalPages };
58
+ }
package/src/index.ts CHANGED
@@ -70,6 +70,8 @@ export function schmock(
70
70
  getState: instance.getState.bind(instance),
71
71
  listen: instance.listen.bind(instance),
72
72
  close: instance.close.bind(instance),
73
+ intercept: (options?: Schmock.InterceptOptions) =>
74
+ instance.intercept(options),
73
75
  },
74
76
  );
75
77
 
@@ -82,6 +84,7 @@ export function schmock(
82
84
  export {
83
85
  HTTP_METHODS,
84
86
  isHttpMethod,
87
+ isRouteNotFound,
85
88
  isStatusTuple,
86
89
  ROUTE_NOT_FOUND_CODE,
87
90
  toHttpMethod,
@@ -99,6 +102,17 @@ export {
99
102
  SchemaValidationError,
100
103
  SchmockError,
101
104
  } from "./errors.js";
105
+ // Re-export response helpers
106
+ export {
107
+ badRequest,
108
+ created,
109
+ forbidden,
110
+ noContent,
111
+ notFound,
112
+ paginate,
113
+ serverError,
114
+ unauthorized,
115
+ } from "./helpers.js";
102
116
  // Re-export HTTP server helpers
103
117
  export {
104
118
  collectBody,
@@ -107,12 +121,19 @@ export {
107
121
  writeSchmockResponse,
108
122
  } from "./http-helpers.js";
109
123
  // Re-export types
124
+ // Re-export interceptor
125
+ export { createFetchInterceptor } from "./interceptor.js";
126
+ // Re-export types
110
127
  export type {
128
+ AdapterRequest,
129
+ AdapterResponse,
111
130
  CallableMockInstance,
112
131
  Generator,
113
132
  GeneratorFunction,
114
133
  GlobalConfig,
115
134
  HttpMethod,
135
+ InterceptHandle,
136
+ InterceptOptions,
116
137
  Plugin,
117
138
  PluginContext,
118
139
  PluginResult,
@@ -0,0 +1,160 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { schmock } from "./index.js";
3
+
4
+ describe("mock.intercept()", () => {
5
+ let originalFetch: typeof globalThis.fetch;
6
+ let mock: Schmock.CallableMockInstance;
7
+
8
+ beforeEach(() => {
9
+ originalFetch = globalThis.fetch;
10
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response("real backend"));
11
+ mock = schmock();
12
+ });
13
+
14
+ afterEach(() => {
15
+ globalThis.fetch = originalFetch;
16
+ });
17
+
18
+ it("intercepts a matched fetch request and returns mocked response", async () => {
19
+ mock("GET /api/users", [{ id: 1, name: "Alice" }]);
20
+ const handle = mock.intercept();
21
+
22
+ const res = await fetch("http://localhost/api/users");
23
+ expect(res.status).toBe(200);
24
+ expect(await res.json()).toEqual([{ id: 1, name: "Alice" }]);
25
+
26
+ handle.restore();
27
+ });
28
+
29
+ it("passes through unmatched routes when passthrough is true", async () => {
30
+ mock("GET /api/users", [{ id: 1 }]);
31
+ // Save reference to the vi.fn() mock that the interceptor will call on passthrough
32
+ const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
33
+ const handle = mock.intercept({ passthrough: true });
34
+
35
+ await fetch("http://localhost/api/other");
36
+ // The interceptor saves the vi.fn() as its original and calls it on passthrough
37
+ expect(mockFetch).toHaveBeenCalled();
38
+ handle.restore();
39
+ });
40
+
41
+ it("returns 404 when passthrough is disabled and route not found", async () => {
42
+ mock("GET /api/users", [{ id: 1 }]);
43
+ const handle = mock.intercept({ passthrough: false });
44
+
45
+ const res = await fetch("http://localhost/api/other");
46
+ expect(res.status).toBe(404);
47
+
48
+ handle.restore();
49
+ });
50
+
51
+ it("restores original fetch", () => {
52
+ const savedFetch = globalThis.fetch;
53
+ const handle = mock.intercept();
54
+
55
+ expect(globalThis.fetch).not.toBe(savedFetch);
56
+ handle.restore();
57
+ expect(globalThis.fetch).toBe(savedFetch);
58
+ });
59
+
60
+ it("reports active status", () => {
61
+ const handle = mock.intercept();
62
+ expect(handle.active).toBe(true);
63
+
64
+ handle.restore();
65
+ expect(handle.active).toBe(false);
66
+ });
67
+
68
+ it("filters by baseUrl", async () => {
69
+ mock("GET /api/users", [{ id: 1 }]);
70
+ const savedFetch = globalThis.fetch;
71
+ const handle = mock.intercept({ baseUrl: "/api" });
72
+
73
+ await fetch("http://localhost/other/path");
74
+ // Should have called the saved fetch (passthrough for non-matching baseUrl)
75
+ expect(savedFetch).toHaveBeenCalled();
76
+
77
+ handle.restore();
78
+ });
79
+
80
+ it("throws when intercepting twice", () => {
81
+ const handle = mock.intercept();
82
+ expect(() => mock.intercept()).toThrow(/already intercepting/i);
83
+ handle.restore();
84
+ });
85
+
86
+ it("applies beforeRequest hook", async () => {
87
+ mock("GET /api/users", ({ headers }) => [
88
+ 200,
89
+ { token: headers["x-token"] },
90
+ ]);
91
+ const handle = mock.intercept({
92
+ beforeRequest: (req) => ({
93
+ ...req,
94
+ headers: { ...req.headers, "x-token": "injected" },
95
+ }),
96
+ });
97
+
98
+ const res = await fetch("http://localhost/api/users");
99
+ expect(await res.json()).toEqual({ token: "injected" });
100
+
101
+ handle.restore();
102
+ });
103
+
104
+ it("applies beforeResponse hook", async () => {
105
+ mock("GET /api/users", [{ id: 1 }]);
106
+ const handle = mock.intercept({
107
+ beforeResponse: (resp) => ({
108
+ ...resp,
109
+ headers: { ...resp.headers, "x-mock": "true" },
110
+ }),
111
+ });
112
+
113
+ const res = await fetch("http://localhost/api/users");
114
+ expect(res.headers.get("x-mock")).toBe("true");
115
+
116
+ handle.restore();
117
+ });
118
+
119
+ it("handles relative URLs", async () => {
120
+ mock("GET /api/users", [{ id: 1 }]);
121
+ const handle = mock.intercept();
122
+
123
+ const res = await fetch("/api/users");
124
+ expect(res.status).toBe(200);
125
+ expect(await res.json()).toEqual([{ id: 1 }]);
126
+
127
+ handle.restore();
128
+ });
129
+
130
+ it("applies errorFormatter when beforeRequest throws", async () => {
131
+ mock("GET /api/users", [{ id: 1 }]);
132
+ const handle = mock.intercept({
133
+ beforeRequest: () => {
134
+ throw new Error("hook failed");
135
+ },
136
+ errorFormatter: (err) => ({ custom: err.message }),
137
+ });
138
+
139
+ const res = await fetch("http://localhost/api/users");
140
+ expect(res.status).toBe(500);
141
+ expect(await res.json()).toEqual({ custom: "hook failed" });
142
+
143
+ handle.restore();
144
+ });
145
+
146
+ it("parses JSON body from fetch init", async () => {
147
+ mock("POST /api/users", ({ body }) => [201, body]);
148
+ const handle = mock.intercept();
149
+
150
+ const res = await fetch("http://localhost/api/users", {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json" },
153
+ body: JSON.stringify({ name: "Alice" }),
154
+ });
155
+ expect(res.status).toBe(201);
156
+ expect(await res.json()).toEqual({ name: "Alice" });
157
+
158
+ handle.restore();
159
+ });
160
+ });
@@ -0,0 +1,252 @@
1
+ /// <reference path="../schmock.d.ts" />
2
+
3
+ import { isRouteNotFound, toHttpMethod } from "./constants.js";
4
+
5
+ /**
6
+ * Extract pathname from a URL string, handling both absolute and relative URLs.
7
+ */
8
+ function extractPathname(url: string): string {
9
+ const queryStart = url.indexOf("?");
10
+ const urlWithoutQuery = queryStart === -1 ? url : url.slice(0, queryStart);
11
+
12
+ if (urlWithoutQuery.includes("://")) {
13
+ try {
14
+ return new URL(urlWithoutQuery).pathname;
15
+ } catch {
16
+ // Fall through to simple extraction
17
+ }
18
+ }
19
+
20
+ if (!urlWithoutQuery.startsWith("/")) {
21
+ return `/${urlWithoutQuery}`;
22
+ }
23
+
24
+ return urlWithoutQuery;
25
+ }
26
+
27
+ /**
28
+ * Extract query parameters from a URL string.
29
+ */
30
+ function extractQuery(url: string): Record<string, string> {
31
+ const queryStart = url.indexOf("?");
32
+ if (queryStart === -1) return {};
33
+
34
+ const params = new URLSearchParams(url.slice(queryStart + 1));
35
+ const result: Record<string, string> = {};
36
+ params.forEach((value, key) => {
37
+ result[key] = value;
38
+ });
39
+ return result;
40
+ }
41
+
42
+ /**
43
+ * Extract headers from fetch init or Request object.
44
+ */
45
+ function extractHeaders(
46
+ input: RequestInfo | URL,
47
+ init?: RequestInit,
48
+ ): Record<string, string> {
49
+ const headers: Record<string, string> = {};
50
+
51
+ const raw = input instanceof Request ? input.headers : init?.headers;
52
+ if (!raw) return headers;
53
+
54
+ if (raw instanceof Headers) {
55
+ raw.forEach((value, key) => {
56
+ headers[key] = value;
57
+ });
58
+ } else if (Array.isArray(raw)) {
59
+ for (const [key, value] of raw) {
60
+ headers[key] = value;
61
+ }
62
+ } else {
63
+ Object.assign(headers, raw);
64
+ }
65
+
66
+ return headers;
67
+ }
68
+
69
+ /**
70
+ * Extract body from fetch init, parsing JSON when possible.
71
+ */
72
+ async function extractBody(
73
+ input: RequestInfo | URL,
74
+ init?: RequestInit,
75
+ ): Promise<unknown> {
76
+ const rawBody = input instanceof Request ? input.body : init?.body;
77
+ if (rawBody === null || rawBody === undefined) return undefined;
78
+
79
+ // String body — try to parse as JSON
80
+ const bodyInit = init?.body;
81
+ if (typeof bodyInit === "string") {
82
+ try {
83
+ return JSON.parse(bodyInit);
84
+ } catch {
85
+ return bodyInit;
86
+ }
87
+ }
88
+
89
+ // Request with body — clone and read
90
+ if (input instanceof Request && input.body) {
91
+ try {
92
+ return await input.clone().json();
93
+ } catch {
94
+ try {
95
+ return await input.clone().text();
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ }
101
+
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Create a fetch interceptor that routes requests through mock.handle().
107
+ */
108
+ export function createFetchInterceptor(
109
+ handle: (
110
+ method: Schmock.HttpMethod,
111
+ path: string,
112
+ requestOptions?: Schmock.RequestOptions,
113
+ ) => Promise<Schmock.Response>,
114
+ options: Schmock.InterceptOptions = {},
115
+ ): Schmock.InterceptHandle {
116
+ const {
117
+ baseUrl,
118
+ passthrough = true,
119
+ beforeRequest,
120
+ beforeResponse,
121
+ errorFormatter,
122
+ } = options;
123
+
124
+ const originalFetch = globalThis.fetch;
125
+ let active = true;
126
+
127
+ globalThis.fetch = async (
128
+ input: RequestInfo | URL,
129
+ init?: RequestInit,
130
+ ): Promise<Response> => {
131
+ // Resolve the URL string
132
+ const urlString =
133
+ input instanceof Request
134
+ ? input.url
135
+ : input instanceof URL
136
+ ? input.href
137
+ : input;
138
+
139
+ const path = extractPathname(urlString);
140
+
141
+ // BaseUrl filter — non-matching requests go straight to real fetch
142
+ if (baseUrl && !path.startsWith(baseUrl)) {
143
+ return originalFetch(input, init);
144
+ }
145
+
146
+ // Build adapter request
147
+ const method =
148
+ input instanceof Request ? input.method : (init?.method ?? "GET");
149
+ const headers = extractHeaders(input, init);
150
+ const query = extractQuery(urlString);
151
+ const body = await extractBody(input, init);
152
+
153
+ let adapterRequest: Schmock.AdapterRequest = {
154
+ method,
155
+ path,
156
+ headers,
157
+ body,
158
+ query,
159
+ };
160
+
161
+ try {
162
+ // Apply beforeRequest hook
163
+ if (beforeRequest) {
164
+ const modified = await beforeRequest(adapterRequest);
165
+ if (modified) {
166
+ adapterRequest = modified;
167
+ }
168
+ }
169
+
170
+ const schmockResponse = await handle(
171
+ toHttpMethod(adapterRequest.method),
172
+ adapterRequest.path,
173
+ {
174
+ headers: adapterRequest.headers,
175
+ body: adapterRequest.body,
176
+ query: adapterRequest.query,
177
+ },
178
+ );
179
+
180
+ // Route not found — passthrough or 404
181
+ if (isRouteNotFound(schmockResponse)) {
182
+ if (passthrough) {
183
+ return originalFetch(input, init);
184
+ }
185
+ return new Response(
186
+ JSON.stringify({
187
+ error: "No matching mock route found",
188
+ code: "ROUTE_NOT_FOUND",
189
+ }),
190
+ {
191
+ status: 404,
192
+ headers: { "content-type": "application/json" },
193
+ },
194
+ );
195
+ }
196
+
197
+ // Apply beforeResponse hook
198
+ let response: Schmock.AdapterResponse = schmockResponse;
199
+ if (beforeResponse) {
200
+ const modified = await beforeResponse(response, adapterRequest);
201
+ if (modified) {
202
+ response = modified;
203
+ }
204
+ }
205
+
206
+ // Build fetch Response
207
+ const responseHeaders = new Headers(response.headers);
208
+ if (
209
+ !responseHeaders.has("content-type") &&
210
+ response.body !== null &&
211
+ response.body !== undefined
212
+ ) {
213
+ responseHeaders.set("content-type", "application/json");
214
+ }
215
+
216
+ const responseBody =
217
+ response.body === null || response.body === undefined
218
+ ? null
219
+ : typeof response.body === "string"
220
+ ? response.body
221
+ : JSON.stringify(response.body);
222
+
223
+ return new Response(responseBody, {
224
+ status: response.status,
225
+ headers: responseHeaders,
226
+ });
227
+ } catch (error) {
228
+ if (errorFormatter) {
229
+ const formatted = errorFormatter(
230
+ error instanceof Error ? error : new Error(String(error)),
231
+ );
232
+ return new Response(JSON.stringify(formatted), {
233
+ status: 500,
234
+ headers: { "content-type": "application/json" },
235
+ });
236
+ }
237
+ throw error;
238
+ }
239
+ };
240
+
241
+ return {
242
+ restore() {
243
+ if (active) {
244
+ globalThis.fetch = originalFetch;
245
+ active = false;
246
+ }
247
+ },
248
+ get active() {
249
+ return active;
250
+ },
251
+ };
252
+ }
@@ -30,7 +30,7 @@ export async function runPluginPipeline(
30
30
  try {
31
31
  const result = await plugin.process(currentContext, response);
32
32
 
33
- if (!result || !result.context) {
33
+ if (!result?.context) {
34
34
  throw new Error(`Plugin ${plugin.name} didn't return valid result`);
35
35
  }
36
36