@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,272 @@
|
|
|
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 =
|
|
52
|
+
init?.headers ?? (input instanceof Request ? input.headers : undefined);
|
|
53
|
+
if (!raw) return headers;
|
|
54
|
+
|
|
55
|
+
if (raw instanceof Headers) {
|
|
56
|
+
raw.forEach((value, key) => {
|
|
57
|
+
headers[key] = value;
|
|
58
|
+
});
|
|
59
|
+
} else if (Array.isArray(raw)) {
|
|
60
|
+
for (const [key, value] of raw) {
|
|
61
|
+
headers[key.toLowerCase()] = value;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
for (const key of Object.keys(raw)) {
|
|
65
|
+
headers[key.toLowerCase()] = (raw as Record<string, string>)[key];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return headers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract body from fetch init, parsing JSON when possible.
|
|
74
|
+
*/
|
|
75
|
+
async function extractBody(
|
|
76
|
+
input: RequestInfo | URL,
|
|
77
|
+
init?: RequestInit,
|
|
78
|
+
): Promise<unknown> {
|
|
79
|
+
// Per Fetch spec, init.body overrides Request.body when both are present
|
|
80
|
+
const bodyInit = init?.body ?? (input instanceof Request ? input.body : null);
|
|
81
|
+
if (bodyInit === null || bodyInit === undefined) return undefined;
|
|
82
|
+
|
|
83
|
+
// String body — try to parse as JSON
|
|
84
|
+
if (typeof bodyInit === "string") {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(bodyInit);
|
|
87
|
+
} catch {
|
|
88
|
+
return bodyInit;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// URLSearchParams — convert to key/value object
|
|
93
|
+
if (bodyInit instanceof URLSearchParams) {
|
|
94
|
+
const result: Record<string, string> = {};
|
|
95
|
+
bodyInit.forEach((value, key) => {
|
|
96
|
+
result[key] = value;
|
|
97
|
+
});
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Request with body — clone and read
|
|
102
|
+
if (input instanceof Request && !init?.body && input.body) {
|
|
103
|
+
try {
|
|
104
|
+
return await input.clone().json();
|
|
105
|
+
} catch {
|
|
106
|
+
try {
|
|
107
|
+
return await input.clone().text();
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create a fetch interceptor that routes requests through mock.handle().
|
|
119
|
+
*/
|
|
120
|
+
export function createFetchInterceptor(
|
|
121
|
+
handle: (
|
|
122
|
+
method: Schmock.HttpMethod,
|
|
123
|
+
path: string,
|
|
124
|
+
requestOptions?: Schmock.RequestOptions,
|
|
125
|
+
) => Promise<Schmock.Response>,
|
|
126
|
+
options: Schmock.InterceptOptions = {},
|
|
127
|
+
): Schmock.InterceptHandle {
|
|
128
|
+
const {
|
|
129
|
+
baseUrl,
|
|
130
|
+
passthrough = true,
|
|
131
|
+
beforeRequest,
|
|
132
|
+
beforeResponse,
|
|
133
|
+
errorFormatter,
|
|
134
|
+
} = options;
|
|
135
|
+
|
|
136
|
+
const originalFetch = globalThis.fetch;
|
|
137
|
+
let active = true;
|
|
138
|
+
|
|
139
|
+
globalThis.fetch = async (
|
|
140
|
+
input: RequestInfo | URL,
|
|
141
|
+
init?: RequestInit,
|
|
142
|
+
): Promise<Response> => {
|
|
143
|
+
// Resolve the URL string
|
|
144
|
+
const urlString =
|
|
145
|
+
input instanceof Request
|
|
146
|
+
? input.url
|
|
147
|
+
: input instanceof URL
|
|
148
|
+
? input.href
|
|
149
|
+
: input;
|
|
150
|
+
|
|
151
|
+
const path = extractPathname(urlString);
|
|
152
|
+
|
|
153
|
+
// BaseUrl filter — non-matching requests go straight to real fetch
|
|
154
|
+
// Enforce segment boundary: /api must not match /apiv2
|
|
155
|
+
if (baseUrl) {
|
|
156
|
+
const normalizedBase = baseUrl.endsWith("/")
|
|
157
|
+
? baseUrl.slice(0, -1)
|
|
158
|
+
: baseUrl;
|
|
159
|
+
const isMatch =
|
|
160
|
+
path === normalizedBase || path.startsWith(`${normalizedBase}/`);
|
|
161
|
+
if (!isMatch) {
|
|
162
|
+
return originalFetch(input, init);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Build adapter request
|
|
167
|
+
const method =
|
|
168
|
+
input instanceof Request ? input.method : (init?.method ?? "GET");
|
|
169
|
+
const headers = extractHeaders(input, init);
|
|
170
|
+
const query = extractQuery(urlString);
|
|
171
|
+
const body = await extractBody(input, init);
|
|
172
|
+
|
|
173
|
+
let adapterRequest: Schmock.AdapterRequest = {
|
|
174
|
+
method,
|
|
175
|
+
path,
|
|
176
|
+
headers,
|
|
177
|
+
body,
|
|
178
|
+
query,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
// Apply beforeRequest hook
|
|
183
|
+
if (beforeRequest) {
|
|
184
|
+
const modified = await beforeRequest(adapterRequest);
|
|
185
|
+
if (modified) {
|
|
186
|
+
adapterRequest = modified;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const schmockResponse = await handle(
|
|
191
|
+
toHttpMethod(adapterRequest.method),
|
|
192
|
+
adapterRequest.path,
|
|
193
|
+
{
|
|
194
|
+
headers: adapterRequest.headers,
|
|
195
|
+
body: adapterRequest.body,
|
|
196
|
+
query: adapterRequest.query,
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Route not found — passthrough or 404
|
|
201
|
+
if (isRouteNotFound(schmockResponse)) {
|
|
202
|
+
if (passthrough) {
|
|
203
|
+
return originalFetch(input, init);
|
|
204
|
+
}
|
|
205
|
+
return new Response(
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
error: "No matching mock route found",
|
|
208
|
+
code: "ROUTE_NOT_FOUND",
|
|
209
|
+
}),
|
|
210
|
+
{
|
|
211
|
+
status: 404,
|
|
212
|
+
headers: { "content-type": "application/json" },
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Apply beforeResponse hook
|
|
218
|
+
let response: Schmock.AdapterResponse = schmockResponse;
|
|
219
|
+
if (beforeResponse) {
|
|
220
|
+
const modified = await beforeResponse(response, adapterRequest);
|
|
221
|
+
if (modified) {
|
|
222
|
+
response = modified;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Build fetch Response
|
|
227
|
+
const responseHeaders = new Headers(response.headers);
|
|
228
|
+
if (
|
|
229
|
+
!responseHeaders.has("content-type") &&
|
|
230
|
+
response.body !== null &&
|
|
231
|
+
response.body !== undefined
|
|
232
|
+
) {
|
|
233
|
+
responseHeaders.set("content-type", "application/json");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const responseBody =
|
|
237
|
+
response.body === null || response.body === undefined
|
|
238
|
+
? null
|
|
239
|
+
: typeof response.body === "string"
|
|
240
|
+
? response.body
|
|
241
|
+
: JSON.stringify(response.body);
|
|
242
|
+
|
|
243
|
+
return new Response(responseBody, {
|
|
244
|
+
status: response.status,
|
|
245
|
+
headers: responseHeaders,
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (errorFormatter) {
|
|
249
|
+
const formatted = errorFormatter(
|
|
250
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
251
|
+
);
|
|
252
|
+
return new Response(JSON.stringify(formatted), {
|
|
253
|
+
status: 500,
|
|
254
|
+
headers: { "content-type": "application/json" },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
restore() {
|
|
263
|
+
if (active) {
|
|
264
|
+
globalThis.fetch = originalFetch;
|
|
265
|
+
active = false;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
get active() {
|
|
269
|
+
return active;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -491,3 +491,104 @@ describe("handle() — fuzz", () => {
|
|
|
491
491
|
);
|
|
492
492
|
});
|
|
493
493
|
});
|
|
494
|
+
|
|
495
|
+
// ============================================================
|
|
496
|
+
// Route matching round-trip properties
|
|
497
|
+
// ============================================================
|
|
498
|
+
|
|
499
|
+
describe("route matching round-trip — property", () => {
|
|
500
|
+
it("parameterized route: arbitrary param values are extracted correctly", () => {
|
|
501
|
+
fc.assert(
|
|
502
|
+
fc.asyncProperty(
|
|
503
|
+
safeSeg,
|
|
504
|
+
safeSeg,
|
|
505
|
+
safeSeg,
|
|
506
|
+
async (resource, paramA, paramB) => {
|
|
507
|
+
const mock = schmock();
|
|
508
|
+
let capturedParams: Record<string, string> = {};
|
|
509
|
+
mock(`GET /${resource}/:a/:b` as any, (ctx) => {
|
|
510
|
+
capturedParams = ctx.params;
|
|
511
|
+
return { ok: true };
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const response = await mock.handle(
|
|
515
|
+
"GET",
|
|
516
|
+
`/${resource}/${paramA}/${paramB}`,
|
|
517
|
+
);
|
|
518
|
+
expect(response.status).toBe(200);
|
|
519
|
+
expect(capturedParams.a).toBe(paramA);
|
|
520
|
+
expect(capturedParams.b).toBe(paramB);
|
|
521
|
+
},
|
|
522
|
+
),
|
|
523
|
+
{ numRuns: 300 },
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("any valid response format produces a valid Response with status/body/headers", () => {
|
|
528
|
+
const responseGen = fc.oneof(
|
|
529
|
+
// Object body
|
|
530
|
+
fc.record({ key: safeSeg }),
|
|
531
|
+
// Tuple [status, body]
|
|
532
|
+
fc.tuple(fc.integer({ min: 100, max: 599 }), fc.record({ v: safeSeg })),
|
|
533
|
+
// Tuple [status, body, headers]
|
|
534
|
+
fc.tuple(
|
|
535
|
+
fc.integer({ min: 100, max: 599 }),
|
|
536
|
+
fc.record({ v: safeSeg }),
|
|
537
|
+
fc.constant({}),
|
|
538
|
+
),
|
|
539
|
+
// null/undefined
|
|
540
|
+
fc.constant(null),
|
|
541
|
+
fc.constant(undefined),
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
fc.assert(
|
|
545
|
+
fc.asyncProperty(responseGen, async (genValue) => {
|
|
546
|
+
const mock = schmock();
|
|
547
|
+
mock("GET /test", () => genValue);
|
|
548
|
+
|
|
549
|
+
const response = await mock.handle("GET", "/test");
|
|
550
|
+
expect(typeof response.status).toBe("number");
|
|
551
|
+
expect(response.status).toBeGreaterThanOrEqual(100);
|
|
552
|
+
expect(response.status).toBeLessThanOrEqual(599);
|
|
553
|
+
expect(response).toHaveProperty("headers");
|
|
554
|
+
expect(typeof response.headers).toBe("object");
|
|
555
|
+
}),
|
|
556
|
+
{ numRuns: 300 },
|
|
557
|
+
);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("header keys are always lowercased after extractHeaders in interceptor", () => {
|
|
561
|
+
const headerKey = fc
|
|
562
|
+
.stringMatching(/^[A-Za-z][A-Za-z0-9-]{0,20}$/)
|
|
563
|
+
.filter((k) => k.length > 0);
|
|
564
|
+
const headerVal = safeSeg;
|
|
565
|
+
|
|
566
|
+
fc.assert(
|
|
567
|
+
fc.asyncProperty(headerKey, headerVal, async (key, val) => {
|
|
568
|
+
const mock = schmock();
|
|
569
|
+
let capturedHeaders: Record<string, string> = {};
|
|
570
|
+
mock("GET /hdr", (ctx) => {
|
|
571
|
+
capturedHeaders = ctx.headers;
|
|
572
|
+
return { ok: true };
|
|
573
|
+
});
|
|
574
|
+
const handle = mock.intercept();
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
await fetch("http://localhost/hdr", {
|
|
578
|
+
headers: { [key]: val },
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// All header keys should be lowercase
|
|
582
|
+
for (const hdrKey of Object.keys(capturedHeaders)) {
|
|
583
|
+
expect(hdrKey).toBe(hdrKey.toLowerCase());
|
|
584
|
+
}
|
|
585
|
+
// The value should be preserved
|
|
586
|
+
expect(capturedHeaders[key.toLowerCase()]).toBe(val);
|
|
587
|
+
} finally {
|
|
588
|
+
handle.restore();
|
|
589
|
+
}
|
|
590
|
+
}),
|
|
591
|
+
{ numRuns: 200 },
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
});
|
package/src/plugin-pipeline.ts
CHANGED
|
@@ -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
|
|
33
|
+
if (!result?.context) {
|
|
34
34
|
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -225,6 +225,80 @@ describe("response parsing", () => {
|
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
+
describe("status code boundaries", () => {
|
|
229
|
+
it("status code 100 (lower boundary) is accepted as tuple", async () => {
|
|
230
|
+
const mock = schmock();
|
|
231
|
+
mock("GET /continue", () => [100, "Continue"]);
|
|
232
|
+
|
|
233
|
+
const response = await mock.handle("GET", "/continue");
|
|
234
|
+
expect(response.status).toBe(100);
|
|
235
|
+
expect(response.body).toBe("Continue");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("status code 599 (upper boundary) is accepted as tuple", async () => {
|
|
239
|
+
const mock = schmock();
|
|
240
|
+
mock("GET /custom", () => [599, { message: "custom status" }]);
|
|
241
|
+
|
|
242
|
+
const response = await mock.handle("GET", "/custom");
|
|
243
|
+
expect(response.status).toBe(599);
|
|
244
|
+
expect(response.body).toEqual({ message: "custom status" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("float status like 200.5 is accepted as tuple (number check)", async () => {
|
|
248
|
+
const mock = schmock();
|
|
249
|
+
mock("GET /float", () => [200.5, { ok: true }]);
|
|
250
|
+
|
|
251
|
+
const response = await mock.handle("GET", "/float");
|
|
252
|
+
// isStatusTuple checks typeof === 'number' and 100 <= n <= 599
|
|
253
|
+
// 200.5 passes both checks, so it's treated as a tuple
|
|
254
|
+
expect(response.status).toBe(200.5);
|
|
255
|
+
expect(response.body).toEqual({ ok: true });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("array [201, null, undefined] — tuple with undefined headers defaults to empty", async () => {
|
|
259
|
+
const mock = schmock();
|
|
260
|
+
mock("GET /null-headers", () => [201, null, undefined] as any);
|
|
261
|
+
|
|
262
|
+
const response = await mock.handle("GET", "/null-headers");
|
|
263
|
+
// isStatusTuple: length 3, first element 201 (number, 100-599) -> true
|
|
264
|
+
// destructure: [status=201, body=null, headers=undefined]
|
|
265
|
+
// headers defaults to {} via `headers = {}`
|
|
266
|
+
expect(response.status).toBe(201);
|
|
267
|
+
expect(response.body).toBeUndefined(); // null body becomes undefined
|
|
268
|
+
expect(response.headers).toEqual({});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("non-tuple array [1, 2, 3, 4] with length > 3 treated as body", async () => {
|
|
272
|
+
const mock = schmock();
|
|
273
|
+
mock("GET /long-array", () => [1, 2, 3, 4] as any);
|
|
274
|
+
|
|
275
|
+
const response = await mock.handle("GET", "/long-array");
|
|
276
|
+
// isStatusTuple requires length 2 or 3, so length 4 => not a tuple => body
|
|
277
|
+
expect(response.status).toBe(200);
|
|
278
|
+
expect(response.body).toEqual([1, 2, 3, 4]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("status 99 (below 100) is NOT treated as a tuple", async () => {
|
|
282
|
+
const mock = schmock();
|
|
283
|
+
mock("GET /below-range", () => [99, { data: true }]);
|
|
284
|
+
|
|
285
|
+
const response = await mock.handle("GET", "/below-range");
|
|
286
|
+
// isStatusTuple requires value[0] >= 100, so 99 fails => treated as body
|
|
287
|
+
expect(response.status).toBe(200);
|
|
288
|
+
expect(response.body).toEqual([99, { data: true }]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("status 600 (above 599) is NOT treated as a tuple", async () => {
|
|
292
|
+
const mock = schmock();
|
|
293
|
+
mock("GET /above-range", () => [600, { data: true }]);
|
|
294
|
+
|
|
295
|
+
const response = await mock.handle("GET", "/above-range");
|
|
296
|
+
// isStatusTuple requires value[0] <= 599, so 600 fails => treated as body
|
|
297
|
+
expect(response.status).toBe(200);
|
|
298
|
+
expect(response.body).toEqual([600, { data: true }]);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
228
302
|
describe("edge cases", () => {
|
|
229
303
|
it("handles response with circular references gracefully", async () => {
|
|
230
304
|
const mock = schmock();
|
package/src/server.test.ts
CHANGED
|
@@ -113,4 +113,53 @@ describe("Standalone Server", () => {
|
|
|
113
113
|
mock.close();
|
|
114
114
|
expect(() => mock.close()).not.toThrow();
|
|
115
115
|
});
|
|
116
|
+
|
|
117
|
+
it("generator that throws synchronously returns 500", async () => {
|
|
118
|
+
mock = schmock();
|
|
119
|
+
mock("GET /boom", () => {
|
|
120
|
+
throw new Error("sync kaboom");
|
|
121
|
+
});
|
|
122
|
+
const info = await mock.listen(0);
|
|
123
|
+
|
|
124
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/boom`);
|
|
125
|
+
expect(res.status).toBe(500);
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
expect(body.error).toBe("sync kaboom");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("generator that returns rejected Promise returns 500", async () => {
|
|
131
|
+
mock = schmock();
|
|
132
|
+
mock("GET /reject", async () => {
|
|
133
|
+
throw new Error("async rejection");
|
|
134
|
+
});
|
|
135
|
+
const info = await mock.listen(0);
|
|
136
|
+
|
|
137
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/reject`);
|
|
138
|
+
expect(res.status).toBe(500);
|
|
139
|
+
const body = await res.json();
|
|
140
|
+
expect(body.error).toBe("async rejection");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("close terminates keep-alive connections immediately", async () => {
|
|
144
|
+
mock = schmock();
|
|
145
|
+
mock("GET /test", { ok: true });
|
|
146
|
+
const info = await mock.listen(0);
|
|
147
|
+
|
|
148
|
+
// Make a keep-alive request to establish a persistent connection
|
|
149
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/test`, {
|
|
150
|
+
headers: { connection: "keep-alive" },
|
|
151
|
+
});
|
|
152
|
+
expect(res.status).toBe(200);
|
|
153
|
+
|
|
154
|
+
// Close the server — should terminate all connections
|
|
155
|
+
mock.close();
|
|
156
|
+
|
|
157
|
+
// Subsequent request should fail immediately (not hang on keep-alive)
|
|
158
|
+
try {
|
|
159
|
+
await fetch(`http://127.0.0.1:${info.port}/test`);
|
|
160
|
+
expect.unreachable("Should have thrown");
|
|
161
|
+
} catch {
|
|
162
|
+
// Expected: connection refused
|
|
163
|
+
}
|
|
164
|
+
});
|
|
116
165
|
});
|
|
@@ -64,41 +64,6 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
Scenario("Multiple async generators in different routes", ({ Given, When, Then, And }) => {
|
|
68
|
-
Given("I create a mock with async routes for posts and comments", () => {
|
|
69
|
-
mock = schmock();
|
|
70
|
-
mock("GET /async-posts", async () => {
|
|
71
|
-
await new Promise(resolve => setTimeout(resolve, 5));
|
|
72
|
-
return [{ id: 1, title: "First Post" }];
|
|
73
|
-
});
|
|
74
|
-
mock("GET /async-comments", async () => {
|
|
75
|
-
await new Promise(resolve => setTimeout(resolve, 8));
|
|
76
|
-
return [{ id: 1, comment: "Great post!" }];
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
When("I make concurrent requests to {string} and {string}", async (_, path1: string, path2: string) => {
|
|
81
|
-
const [posts, comments] = await Promise.all([
|
|
82
|
-
mock.handle("GET", path1),
|
|
83
|
-
mock.handle("GET", path2),
|
|
84
|
-
]);
|
|
85
|
-
responses = [posts, comments];
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
Then("both responses should be returned successfully", () => {
|
|
89
|
-
expect(responses[0].status).toBe(200);
|
|
90
|
-
expect(responses[1].status).toBe(200);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
And("the posts response should contain {string}", (_, text: string) => {
|
|
94
|
-
expect(JSON.stringify(responses[0].body)).toContain(text);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
And("the comments response should contain {string}", (_, text: string) => {
|
|
98
|
-
expect(JSON.stringify(responses[1].body)).toContain(text);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
67
|
Scenario("Async plugin processing", ({ Given, When, Then, And }) => {
|
|
103
68
|
Given("I create a mock with an async processing plugin at {string}", (_, route: string) => {
|
|
104
69
|
const [method, path] = route.split(" ");
|
|
@@ -110,90 +110,6 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
Scenario("Undefined response", ({ Given, When, Then, And }) => {
|
|
114
|
-
Given("I create a mock returning undefined at {string}", (_, route: string) => {
|
|
115
|
-
const [method, path] = route.split(" ");
|
|
116
|
-
mock = schmock();
|
|
117
|
-
mock(`${method} ${path}` as Schmock.RouteKey, undefined);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
When("I request {string}", async (_, request: string) => {
|
|
121
|
-
const [method, path] = request.split(" ");
|
|
122
|
-
response = await mock.handle(method as any, path);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
Then("I should receive empty response", () => {
|
|
126
|
-
expect(response.body).toBeUndefined();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
And("the status should be {int}", (_, status: number) => {
|
|
130
|
-
expect(response.status).toBe(status);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
Scenario("Empty string response", ({ Given, When, Then, And }) => {
|
|
135
|
-
Given("I create a mock returning empty string at {string}", (_, route: string) => {
|
|
136
|
-
const [method, path] = route.split(" ");
|
|
137
|
-
mock = schmock();
|
|
138
|
-
mock(`${method} ${path}` as Schmock.RouteKey, "");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
When("I request {string}", async (_, request: string) => {
|
|
142
|
-
const [method, path] = request.split(" ");
|
|
143
|
-
response = await mock.handle(method as any, path);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
Then("I should receive text {string}", (_, expectedText: string) => {
|
|
147
|
-
expect(response.body).toBe(expectedText);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
And("the content-type should be {string}", (_, contentType: string) => {
|
|
151
|
-
expect(response.headers?.["content-type"]).toBe(contentType);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
Scenario("Number response", ({ Given, When, Then, And }) => {
|
|
156
|
-
Given("I create a mock returning number {int} at {string}", (_, num: number, route: string) => {
|
|
157
|
-
const [method, path] = route.split(" ");
|
|
158
|
-
mock = schmock();
|
|
159
|
-
mock(`${method} ${path}` as Schmock.RouteKey, num);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
When("I request {string}", async (_, request: string) => {
|
|
163
|
-
const [method, path] = request.split(" ");
|
|
164
|
-
response = await mock.handle(method as any, path);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
Then("I should receive text {string}", (_, expectedText: string) => {
|
|
168
|
-
expect(response.body).toBe(expectedText);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
And("the content-type should be {string}", (_, contentType: string) => {
|
|
172
|
-
expect(response.headers?.["content-type"]).toBe(contentType);
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
Scenario("Boolean response", ({ Given, When, Then, And }) => {
|
|
177
|
-
Given("I create a mock returning boolean true at {string}", (_, route: string) => {
|
|
178
|
-
const [method, path] = route.split(" ");
|
|
179
|
-
mock = schmock();
|
|
180
|
-
mock(`${method} ${path}` as Schmock.RouteKey, true);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
When("I request {string}", async (_, request: string) => {
|
|
184
|
-
const [method, path] = request.split(" ");
|
|
185
|
-
response = await mock.handle(method as any, path);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
Then("I should receive text {string}", (_, expectedText: string) => {
|
|
189
|
-
expect(response.body).toBe(expectedText);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
And("the content-type should be {string}", (_, contentType: string) => {
|
|
193
|
-
expect(response.headers?.["content-type"]).toBe(contentType);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
113
|
Scenario("Function returning string", ({ Given, When, Then }) => {
|
|
198
114
|
Given("I create a mock with a string generator at {string}", (_, route: string) => {
|
|
199
115
|
const [method, path] = route.split(" ");
|