@schmock/core 1.13.0 → 2.0.1
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.
- package/dist/builder.d.ts +2 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +13 -0
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +12 -0
- package/dist/helpers.d.ts +9 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +37 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/interceptor.d.ts +5 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/interceptor.js +213 -0
- package/dist/plugin-pipeline.js +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/builder.ts +23 -0
- package/src/constants.test.ts +40 -0
- package/src/constants.ts +18 -0
- package/src/helpers.test.ts +147 -0
- package/src/helpers.ts +58 -0
- package/src/index.ts +21 -0
- package/src/interceptor.test.ts +291 -0
- package/src/interceptor.ts +272 -0
- package/src/parser.property.test.ts +101 -0
- package/src/plugin-pipeline.ts +1 -1
- package/src/response-parsing.test.ts +74 -0
- package/src/server.test.ts +49 -0
- package/src/steps/async-support.steps.ts +0 -35
- package/src/steps/basic-usage.steps.ts +0 -84
- package/src/steps/developer-experience.steps.ts +0 -269
- package/src/steps/error-handling.steps.ts +0 -66
- package/src/steps/http-methods.steps.ts +0 -66
- package/src/steps/interceptor.steps.ts +206 -0
- package/src/steps/request-history.steps.ts +0 -75
- package/src/steps/route-key-format.steps.ts +0 -19
- package/src/types.ts +5 -0
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
|
|
107
|
+
it("page=0 falls back to page 1 (falsy default)", () => {
|
|
108
|
+
const result = paginate(items, { page: 0, pageSize: 2 });
|
|
109
|
+
// page=0 is falsy, so options.page || 1 => 1
|
|
110
|
+
expect(result.page).toBe(1);
|
|
111
|
+
expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("pageSize=0 falls back to default 10 (falsy default)", () => {
|
|
115
|
+
const result = paginate(items, { pageSize: 0 });
|
|
116
|
+
// pageSize=0 is falsy, so options.pageSize || 10 => 10
|
|
117
|
+
expect(result.pageSize).toBe(10);
|
|
118
|
+
expect(result.data).toEqual(items);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("handles empty array", () => {
|
|
122
|
+
const result = paginate([], { page: 1, pageSize: 5 });
|
|
123
|
+
expect(result).toEqual({
|
|
124
|
+
data: [],
|
|
125
|
+
page: 1,
|
|
126
|
+
pageSize: 5,
|
|
127
|
+
total: 0,
|
|
128
|
+
totalPages: 0,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("negative page produces empty data (start index < 0)", () => {
|
|
133
|
+
const result = paginate(items, { page: -1, pageSize: 2 });
|
|
134
|
+
// (page - 1) * pageSize = (-1 - 1) * 2 = -4
|
|
135
|
+
// items.slice(-4, -2) => items from index 1 to 3
|
|
136
|
+
expect(result.page).toBe(-1);
|
|
137
|
+
expect(result.pageSize).toBe(2);
|
|
138
|
+
expect(result.total).toBe(5);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("negative pageSize returns empty data", () => {
|
|
142
|
+
const result = paginate(items, { page: 1, pageSize: -5 });
|
|
143
|
+
// start = 0, end = 0 + (-5) = -5 => items.slice(0, -5) => []
|
|
144
|
+
expect(result.data).toEqual([]);
|
|
145
|
+
expect(result.pageSize).toBe(-5);
|
|
146
|
+
});
|
|
147
|
+
});
|
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,291 @@
|
|
|
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("normalizes header keys to lowercase", async () => {
|
|
147
|
+
mock("POST /api/data", ({ headers }) => [
|
|
148
|
+
200,
|
|
149
|
+
{ auth: headers.authorization, ct: headers["content-type"] },
|
|
150
|
+
]);
|
|
151
|
+
const handle = mock.intercept();
|
|
152
|
+
|
|
153
|
+
const res = await fetch("http://localhost/api/data", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
Authorization: "Bearer tok",
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({}),
|
|
160
|
+
});
|
|
161
|
+
expect(await res.json()).toEqual({
|
|
162
|
+
auth: "Bearer tok",
|
|
163
|
+
ct: "application/json",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
handle.restore();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("parses JSON body from fetch init", async () => {
|
|
170
|
+
mock("POST /api/users", ({ body }) => [201, body]);
|
|
171
|
+
const handle = mock.intercept();
|
|
172
|
+
|
|
173
|
+
const res = await fetch("http://localhost/api/users", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "content-type": "application/json" },
|
|
176
|
+
body: JSON.stringify({ name: "Alice" }),
|
|
177
|
+
});
|
|
178
|
+
expect(res.status).toBe(201);
|
|
179
|
+
expect(await res.json()).toEqual({ name: "Alice" });
|
|
180
|
+
|
|
181
|
+
handle.restore();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("intercepts fetch called with a Request object", async () => {
|
|
185
|
+
mock("GET /api/users", [{ id: 1, name: "Alice" }]);
|
|
186
|
+
const handle = mock.intercept();
|
|
187
|
+
|
|
188
|
+
const req = new Request("http://localhost/api/users");
|
|
189
|
+
const res = await fetch(req);
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(await res.json()).toEqual([{ id: 1, name: "Alice" }]);
|
|
192
|
+
|
|
193
|
+
handle.restore();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("prefers init.headers over Request.headers", async () => {
|
|
197
|
+
mock("GET /api/data", ({ headers }) => [200, { val: headers["x-custom"] }]);
|
|
198
|
+
const handle = mock.intercept();
|
|
199
|
+
|
|
200
|
+
const req = new Request("http://localhost/api/data", {
|
|
201
|
+
headers: { "X-Custom": "from-request" },
|
|
202
|
+
});
|
|
203
|
+
const res = await fetch(req, {
|
|
204
|
+
headers: { "X-Custom": "from-init" },
|
|
205
|
+
});
|
|
206
|
+
expect(await res.json()).toEqual({ val: "from-init" });
|
|
207
|
+
|
|
208
|
+
handle.restore();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("parses URLSearchParams body", async () => {
|
|
212
|
+
mock("POST /api/form", ({ body }) => [200, body]);
|
|
213
|
+
const handle = mock.intercept();
|
|
214
|
+
|
|
215
|
+
const params = new URLSearchParams();
|
|
216
|
+
params.set("name", "Alice");
|
|
217
|
+
params.set("role", "admin");
|
|
218
|
+
|
|
219
|
+
const res = await fetch("http://localhost/api/form", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: params,
|
|
222
|
+
});
|
|
223
|
+
expect(await res.json()).toEqual({ name: "Alice", role: "admin" });
|
|
224
|
+
|
|
225
|
+
handle.restore();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("baseUrl /api should NOT match /apiv2 (segment boundary)", async () => {
|
|
229
|
+
mock("GET /apiv2/data", [{ v2: true }]);
|
|
230
|
+
const savedFetch = globalThis.fetch;
|
|
231
|
+
const handle = mock.intercept({ baseUrl: "/api" });
|
|
232
|
+
|
|
233
|
+
// /apiv2/data does not start with "/api/" — it should passthrough
|
|
234
|
+
await fetch("http://localhost/apiv2/data");
|
|
235
|
+
expect(savedFetch).toHaveBeenCalled();
|
|
236
|
+
|
|
237
|
+
handle.restore();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("baseUrl with trailing slash matches the same routes as without", async () => {
|
|
241
|
+
mock("GET /api/items", [{ id: 1 }]);
|
|
242
|
+
|
|
243
|
+
// Without trailing slash
|
|
244
|
+
const handle1 = mock.intercept({ baseUrl: "/api" });
|
|
245
|
+
const res1 = await fetch("http://localhost/api/items");
|
|
246
|
+
expect(res1.status).toBe(200);
|
|
247
|
+
expect(await res1.json()).toEqual([{ id: 1 }]);
|
|
248
|
+
handle1.restore();
|
|
249
|
+
|
|
250
|
+
// With trailing slash — should still match /api/items
|
|
251
|
+
const handle2 = mock.intercept({ baseUrl: "/api/" });
|
|
252
|
+
const res2 = await fetch("http://localhost/api/items");
|
|
253
|
+
expect(res2.status).toBe(200);
|
|
254
|
+
expect(await res2.json()).toEqual([{ id: 1 }]);
|
|
255
|
+
handle2.restore();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("extractBody: init.body wins over Request.body per Fetch spec", async () => {
|
|
259
|
+
mock("POST /api/data", ({ body }) => [200, body]);
|
|
260
|
+
const handle = mock.intercept();
|
|
261
|
+
|
|
262
|
+
const req = new Request("http://localhost/api/data", {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: { "content-type": "application/json" },
|
|
265
|
+
body: JSON.stringify({ source: "request" }),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const res = await fetch(req, {
|
|
269
|
+
body: JSON.stringify({ source: "init" }),
|
|
270
|
+
});
|
|
271
|
+
expect(await res.json()).toEqual({ source: "init" });
|
|
272
|
+
|
|
273
|
+
handle.restore();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("extractBody: failed JSON parse falls back to text", async () => {
|
|
277
|
+
mock("POST /api/text", ({ body }) => [200, { received: body }]);
|
|
278
|
+
const handle = mock.intercept();
|
|
279
|
+
|
|
280
|
+
const res = await fetch("http://localhost/api/text", {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: { "content-type": "text/plain" },
|
|
283
|
+
body: "not-json-{broken",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const json = await res.json();
|
|
287
|
+
expect(json.received).toBe("not-json-{broken");
|
|
288
|
+
|
|
289
|
+
handle.restore();
|
|
290
|
+
});
|
|
291
|
+
});
|