@schmock/openapi 1.1.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.
- package/dist/crud-detector.d.ts +35 -0
- package/dist/crud-detector.d.ts.map +1 -0
- package/dist/crud-detector.js +153 -0
- package/dist/generators.d.ts +14 -0
- package/dist/generators.d.ts.map +1 -0
- package/dist/generators.js +158 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +221 -0
- package/dist/normalizer.d.ts +14 -0
- package/dist/normalizer.d.ts.map +1 -0
- package/dist/normalizer.js +194 -0
- package/dist/parser.d.ts +32 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +282 -0
- package/dist/plugin.d.ts +32 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +129 -0
- package/dist/seed.d.ts +15 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +41 -0
- package/package.json +45 -0
- package/src/__fixtures__/faker-stress-test.openapi.yaml +1030 -0
- package/src/__fixtures__/openapi31.json +34 -0
- package/src/__fixtures__/petstore-openapi3.json +168 -0
- package/src/__fixtures__/petstore-swagger2.json +141 -0
- package/src/__fixtures__/scalar-galaxy.yaml +1314 -0
- package/src/__fixtures__/stripe-fixtures3.json +6542 -0
- package/src/__fixtures__/stripe-spec3.yaml +161621 -0
- package/src/__fixtures__/train-travel.yaml +1264 -0
- package/src/crud-detector.test.ts +150 -0
- package/src/crud-detector.ts +194 -0
- package/src/generators.test.ts +214 -0
- package/src/generators.ts +212 -0
- package/src/index.ts +4 -0
- package/src/normalizer.test.ts +253 -0
- package/src/normalizer.ts +233 -0
- package/src/parser.test.ts +181 -0
- package/src/parser.ts +389 -0
- package/src/plugin.test.ts +205 -0
- package/src/plugin.ts +185 -0
- package/src/seed.ts +62 -0
- package/src/steps/openapi-crud.steps.ts +132 -0
- package/src/steps/openapi-parsing.steps.ts +111 -0
- package/src/steps/openapi-seed.steps.ts +94 -0
- package/src/stress.test.ts +2814 -0
|
@@ -0,0 +1,2814 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { schmock } from "@schmock/core";
|
|
6
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
7
|
+
import { detectCrudResources } from "./crud-detector";
|
|
8
|
+
import { normalizeSchema } from "./normalizer";
|
|
9
|
+
import type { ParsedSpec } from "./parser";
|
|
10
|
+
import { parseSpec } from "./parser";
|
|
11
|
+
import { openapi } from "./plugin";
|
|
12
|
+
|
|
13
|
+
const fixturesDir = resolve(import.meta.dirname, "__fixtures__");
|
|
14
|
+
const trainTravelSpec = resolve(fixturesDir, "train-travel.yaml");
|
|
15
|
+
|
|
16
|
+
// ════════════════════════════════════════════════════════════════════
|
|
17
|
+
// 1. PARSER — real-world Train Travel API (OpenAPI 3.1)
|
|
18
|
+
// ════════════════════════════════════════════════════════════════════
|
|
19
|
+
describe("stress: parser — train-travel.yaml", () => {
|
|
20
|
+
it("parses the OpenAPI 3.1 Train Travel spec", async () => {
|
|
21
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
22
|
+
expect(spec.title).toBe("Train Travel API");
|
|
23
|
+
expect(spec.version).toBe("1.2.1");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("extracts basePath from first server URL", async () => {
|
|
27
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
28
|
+
// servers[0] = "https://try.microcks.io/rest/Train+Travel+API/1.0.0"
|
|
29
|
+
expect(spec.basePath).toBe("/rest/Train+Travel+API/1.0.0");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("extracts all path operations", async () => {
|
|
33
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
34
|
+
const sigs = spec.paths.map((p) => `${p.method} ${p.path}`);
|
|
35
|
+
|
|
36
|
+
expect(sigs).toContain("GET /stations");
|
|
37
|
+
expect(sigs).toContain("GET /trips");
|
|
38
|
+
expect(sigs).toContain("GET /bookings");
|
|
39
|
+
expect(sigs).toContain("POST /bookings");
|
|
40
|
+
expect(sigs).toContain("GET /bookings/:bookingId");
|
|
41
|
+
expect(sigs).toContain("DELETE /bookings/:bookingId");
|
|
42
|
+
expect(sigs).toContain("POST /bookings/:bookingId/payment");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("resolves $ref'd parameters (page, limit)", async () => {
|
|
46
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
47
|
+
const getStations = spec.paths.find(
|
|
48
|
+
(p) => p.method === "GET" && p.path === "/stations",
|
|
49
|
+
);
|
|
50
|
+
expect(getStations).toBeDefined();
|
|
51
|
+
|
|
52
|
+
const paramNames = getStations?.parameters.map((p) => p.name) ?? [];
|
|
53
|
+
expect(paramNames).toContain("page");
|
|
54
|
+
expect(paramNames).toContain("limit");
|
|
55
|
+
expect(paramNames).toContain("coordinates");
|
|
56
|
+
expect(paramNames).toContain("search");
|
|
57
|
+
expect(paramNames).toContain("country");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("resolves $ref'd response schemas (allOf composition)", async () => {
|
|
61
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
62
|
+
const getStations = spec.paths.find(
|
|
63
|
+
(p) => p.method === "GET" && p.path === "/stations",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// 200 response exists with a schema
|
|
67
|
+
expect(getStations?.responses.has(200)).toBe(true);
|
|
68
|
+
const schema200 = getStations?.responses.get(200)?.schema;
|
|
69
|
+
expect(schema200).toBeDefined();
|
|
70
|
+
// The schema is an allOf composition — no unresolved $ref
|
|
71
|
+
const schemaStr = JSON.stringify(schema200);
|
|
72
|
+
expect(schemaStr).not.toContain("$ref");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("resolves $ref'd error responses (400, 401, etc.)", async () => {
|
|
76
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
77
|
+
const getStations = spec.paths.find(
|
|
78
|
+
(p) => p.method === "GET" && p.path === "/stations",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Error responses use application/problem+json — parser should find them
|
|
82
|
+
expect(getStations?.responses.has(400)).toBe(true);
|
|
83
|
+
expect(getStations?.responses.has(401)).toBe(true);
|
|
84
|
+
expect(getStations?.responses.has(500)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("extracts path-level parameters for bookings/:bookingId", async () => {
|
|
88
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
89
|
+
const getBooking = spec.paths.find(
|
|
90
|
+
(p) => p.method === "GET" && p.path === "/bookings/:bookingId",
|
|
91
|
+
);
|
|
92
|
+
expect(getBooking).toBeDefined();
|
|
93
|
+
expect(getBooking?.parameters).toContainEqual(
|
|
94
|
+
expect.objectContaining({ name: "bookingId", in: "path" }),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("extracts requestBody from POST /bookings", async () => {
|
|
99
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
100
|
+
const createBooking = spec.paths.find(
|
|
101
|
+
(p) => p.method === "POST" && p.path === "/bookings",
|
|
102
|
+
);
|
|
103
|
+
expect(createBooking?.requestBody).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("handles non-application/json content types (application/problem+json)", async () => {
|
|
107
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
108
|
+
const getStations = spec.paths.find(
|
|
109
|
+
(p) => p.method === "GET" && p.path === "/stations",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// 400 response uses application/problem+json — parser should still extract schema
|
|
113
|
+
const resp400 = getStations?.responses.get(400);
|
|
114
|
+
expect(resp400?.schema).toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("strips x-* extensions during normalization", async () => {
|
|
118
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
119
|
+
// Verify parsed schemas don't contain x-* extensions
|
|
120
|
+
for (const p of spec.paths) {
|
|
121
|
+
if (p.requestBody) {
|
|
122
|
+
const str = JSON.stringify(p.requestBody);
|
|
123
|
+
expect(str).not.toContain('"x-');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ════════════════════════════════════════════════════════════════════
|
|
130
|
+
// 2. CRUD DETECTOR — Train Travel spec
|
|
131
|
+
// ════════════════════════════════════════════════════════════════════
|
|
132
|
+
describe("stress: crud-detector — train-travel.yaml", () => {
|
|
133
|
+
it("detects bookings as a CRUD resource", async () => {
|
|
134
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
135
|
+
const result = detectCrudResources(spec.paths);
|
|
136
|
+
|
|
137
|
+
const bookings = result.resources.find((r) => r.name === "bookings");
|
|
138
|
+
expect(bookings).toBeDefined();
|
|
139
|
+
expect(bookings?.basePath).toBe("/bookings");
|
|
140
|
+
expect(bookings?.itemPath).toBe("/bookings/:bookingId");
|
|
141
|
+
expect(bookings?.idParam).toBe("bookingId");
|
|
142
|
+
expect(bookings?.operations).toContain("list");
|
|
143
|
+
expect(bookings?.operations).toContain("create");
|
|
144
|
+
expect(bookings?.operations).toContain("read");
|
|
145
|
+
expect(bookings?.operations).toContain("delete");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("classifies stations as non-CRUD (GET only, no create)", async () => {
|
|
149
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
150
|
+
const result = detectCrudResources(spec.paths);
|
|
151
|
+
|
|
152
|
+
const stationsResource = result.resources.find(
|
|
153
|
+
(r) => r.name === "stations",
|
|
154
|
+
);
|
|
155
|
+
expect(stationsResource).toBeUndefined();
|
|
156
|
+
|
|
157
|
+
const stationsNonCrud = result.nonCrudPaths.find(
|
|
158
|
+
(p) => p.path === "/stations",
|
|
159
|
+
);
|
|
160
|
+
expect(stationsNonCrud).toBeDefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("classifies trips as non-CRUD (GET only, no create)", async () => {
|
|
164
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
165
|
+
const result = detectCrudResources(spec.paths);
|
|
166
|
+
|
|
167
|
+
const tripsResource = result.resources.find((r) => r.name === "trips");
|
|
168
|
+
expect(tripsResource).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("classifies /bookings/:bookingId/payment as non-CRUD", async () => {
|
|
172
|
+
const spec = await parseSpec(trainTravelSpec);
|
|
173
|
+
const result = detectCrudResources(spec.paths);
|
|
174
|
+
|
|
175
|
+
// payment is nested 2 levels deep under bookings, not a simple item path
|
|
176
|
+
const paymentNonCrud = result.nonCrudPaths.find(
|
|
177
|
+
(p) => p.path === "/bookings/:bookingId/payment",
|
|
178
|
+
);
|
|
179
|
+
expect(paymentNonCrud).toBeDefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ════════════════════════════════════════════════════════════════════
|
|
184
|
+
// 3. FULL INTEGRATION — Train Travel spec
|
|
185
|
+
// ════════════════════════════════════════════════════════════════════
|
|
186
|
+
describe("stress: integration — train-travel.yaml", () => {
|
|
187
|
+
it("auto-registers all routes and handles requests", async () => {
|
|
188
|
+
const mock = schmock({ state: {} });
|
|
189
|
+
mock.pipe(await openapi({ spec: trainTravelSpec }));
|
|
190
|
+
|
|
191
|
+
// Stations: non-CRUD static endpoint → should return generated response
|
|
192
|
+
const stations = await mock.handle("GET", "/stations");
|
|
193
|
+
expect(stations.status).toBe(200);
|
|
194
|
+
expect(stations.body).toBeDefined();
|
|
195
|
+
|
|
196
|
+
// Trips: non-CRUD static endpoint
|
|
197
|
+
const trips = await mock.handle("GET", "/trips");
|
|
198
|
+
expect(trips.status).toBe(200);
|
|
199
|
+
|
|
200
|
+
// Bookings: CRUD resource
|
|
201
|
+
const emptyList = await mock.handle("GET", "/bookings");
|
|
202
|
+
expect(emptyList.status).toBe(200);
|
|
203
|
+
expect(emptyList.body).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("bookings CRUD lifecycle", async () => {
|
|
207
|
+
const mock = schmock({ state: {} });
|
|
208
|
+
mock.pipe(await openapi({ spec: trainTravelSpec }));
|
|
209
|
+
|
|
210
|
+
// Create
|
|
211
|
+
const created = await mock.handle("POST", "/bookings", {
|
|
212
|
+
body: {
|
|
213
|
+
trip_id: "ea399ba1-6d95-433f-92d1-83f67b775594",
|
|
214
|
+
passenger_name: "John Doe",
|
|
215
|
+
has_bicycle: true,
|
|
216
|
+
has_dog: false,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
expect(created.status).toBe(201);
|
|
220
|
+
const booking = created.body as Record<string, unknown>;
|
|
221
|
+
expect(booking.passenger_name).toBe("John Doe");
|
|
222
|
+
expect(booking.bookingId).toBe(1);
|
|
223
|
+
|
|
224
|
+
// Read
|
|
225
|
+
const read = await mock.handle("GET", "/bookings/1");
|
|
226
|
+
expect(read.status).toBe(200);
|
|
227
|
+
expect(read.body).toMatchObject({ passenger_name: "John Doe" });
|
|
228
|
+
|
|
229
|
+
// List
|
|
230
|
+
const list = await mock.handle("GET", "/bookings");
|
|
231
|
+
expect(list.status).toBe(200);
|
|
232
|
+
expect(list.body).toHaveLength(1);
|
|
233
|
+
|
|
234
|
+
// Delete
|
|
235
|
+
const deleted = await mock.handle("DELETE", "/bookings/1");
|
|
236
|
+
expect(deleted.status).toBe(204);
|
|
237
|
+
|
|
238
|
+
// Verify deletion
|
|
239
|
+
const afterDelete = await mock.handle("GET", "/bookings/1");
|
|
240
|
+
expect(afterDelete.status).toBe(404);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("payment endpoint returns a response (non-CRUD)", async () => {
|
|
244
|
+
const mock = schmock({ state: {} });
|
|
245
|
+
mock.pipe(await openapi({ spec: trainTravelSpec }));
|
|
246
|
+
|
|
247
|
+
// POST /bookings/:bookingId/payment — non-CRUD static
|
|
248
|
+
const payment = await mock.handle("POST", "/bookings/123/payment", {
|
|
249
|
+
body: {
|
|
250
|
+
amount: 49.99,
|
|
251
|
+
currency: "gbp",
|
|
252
|
+
source: { object: "card", name: "J. Doe", number: "4242..." },
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
expect(payment.status).toBe(200);
|
|
256
|
+
expect(payment.body).toBeDefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("multiple bookings with sequential IDs", async () => {
|
|
260
|
+
const mock = schmock({ state: {} });
|
|
261
|
+
mock.pipe(await openapi({ spec: trainTravelSpec }));
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < 5; i++) {
|
|
264
|
+
const res = await mock.handle("POST", "/bookings", {
|
|
265
|
+
body: { passenger_name: `Passenger-${i}`, trip_id: "trip-1" },
|
|
266
|
+
});
|
|
267
|
+
expect(res.status).toBe(201);
|
|
268
|
+
const body = res.body as Record<string, unknown>;
|
|
269
|
+
expect(body.bookingId).toBe(i + 1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const list = await mock.handle("GET", "/bookings");
|
|
273
|
+
expect(list.body).toHaveLength(5);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ════════════════════════════════════════════════════════════════════
|
|
278
|
+
// 4. NORMALIZER STRESS
|
|
279
|
+
// ════════════════════════════════════════════════════════════════════
|
|
280
|
+
describe("stress: normalizer", () => {
|
|
281
|
+
it("handles nullable + exclusiveMinimum combo", () => {
|
|
282
|
+
const result = normalizeSchema(
|
|
283
|
+
{
|
|
284
|
+
type: "number",
|
|
285
|
+
format: "double",
|
|
286
|
+
exclusiveMinimum: true,
|
|
287
|
+
minimum: 0,
|
|
288
|
+
nullable: true,
|
|
289
|
+
},
|
|
290
|
+
"response",
|
|
291
|
+
);
|
|
292
|
+
expect(result.oneOf).toBeDefined();
|
|
293
|
+
const branches = result.oneOf;
|
|
294
|
+
expect(branches).toHaveLength(2);
|
|
295
|
+
const numericBranch = (branches as Record<string, unknown>[])[0];
|
|
296
|
+
expect(numericBranch.exclusiveMinimum).toBe(0);
|
|
297
|
+
expect(numericBranch).not.toHaveProperty("minimum");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("handles exclusiveMaximum: true (boolean → number)", () => {
|
|
301
|
+
const result = normalizeSchema(
|
|
302
|
+
{
|
|
303
|
+
type: "number",
|
|
304
|
+
minimum: 0,
|
|
305
|
+
exclusiveMaximum: true,
|
|
306
|
+
maximum: 300000,
|
|
307
|
+
},
|
|
308
|
+
"response",
|
|
309
|
+
);
|
|
310
|
+
expect(result.exclusiveMaximum).toBe(300000);
|
|
311
|
+
expect(result).not.toHaveProperty("maximum");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("passes through exclusiveMinimum already as number (OpenAPI 3.1)", () => {
|
|
315
|
+
// In 3.1, exclusiveMinimum is already a number — should not be transformed
|
|
316
|
+
const result = normalizeSchema(
|
|
317
|
+
{
|
|
318
|
+
type: "number",
|
|
319
|
+
exclusiveMinimum: 0,
|
|
320
|
+
},
|
|
321
|
+
"response",
|
|
322
|
+
);
|
|
323
|
+
expect(result.exclusiveMinimum).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("strips readOnly from request, keeps in response", () => {
|
|
327
|
+
const schema = {
|
|
328
|
+
type: "object",
|
|
329
|
+
required: ["id", "name"],
|
|
330
|
+
properties: {
|
|
331
|
+
id: { type: "string", format: "uuid", readOnly: true },
|
|
332
|
+
name: { type: "string" },
|
|
333
|
+
createdAt: { type: "string", format: "date-time", readOnly: true },
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const request = normalizeSchema(schema, "request");
|
|
338
|
+
expect(request.properties).not.toHaveProperty("id");
|
|
339
|
+
expect(request.properties).not.toHaveProperty("createdAt");
|
|
340
|
+
expect(request.required).not.toContain("id");
|
|
341
|
+
|
|
342
|
+
const response = normalizeSchema(schema, "response");
|
|
343
|
+
expect(response.properties).toHaveProperty("id");
|
|
344
|
+
expect(response.properties).toHaveProperty("createdAt");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("strips writeOnly from response, keeps in request", () => {
|
|
348
|
+
const schema = {
|
|
349
|
+
type: "object",
|
|
350
|
+
properties: {
|
|
351
|
+
username: { type: "string" },
|
|
352
|
+
cvc: { type: "string", writeOnly: true },
|
|
353
|
+
address_line1: { type: "string", writeOnly: true },
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const response = normalizeSchema(schema, "response");
|
|
358
|
+
expect(response.properties).not.toHaveProperty("cvc");
|
|
359
|
+
expect(response.properties).not.toHaveProperty("address_line1");
|
|
360
|
+
|
|
361
|
+
const request = normalizeSchema(schema, "request");
|
|
362
|
+
expect(request.properties).toHaveProperty("cvc");
|
|
363
|
+
expect(request.properties).toHaveProperty("address_line1");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("handles allOf compositions (Wrapper-Collection pattern)", () => {
|
|
367
|
+
const result = normalizeSchema(
|
|
368
|
+
{
|
|
369
|
+
allOf: [
|
|
370
|
+
{
|
|
371
|
+
type: "object",
|
|
372
|
+
properties: {
|
|
373
|
+
data: { type: "array", items: { type: "object" } },
|
|
374
|
+
links: { type: "object", readOnly: true },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
properties: {
|
|
379
|
+
data: {
|
|
380
|
+
type: "array",
|
|
381
|
+
items: {
|
|
382
|
+
type: "object",
|
|
383
|
+
properties: {
|
|
384
|
+
id: { type: "string", format: "uuid" },
|
|
385
|
+
name: { type: "string", example: "Berlin Hbf" },
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
"response",
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(result.allOf).toBeDefined();
|
|
397
|
+
expect(result.allOf).toHaveLength(2);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("handles oneOf with const (card vs bank_account)", () => {
|
|
401
|
+
const result = normalizeSchema(
|
|
402
|
+
{
|
|
403
|
+
oneOf: [
|
|
404
|
+
{
|
|
405
|
+
type: "object",
|
|
406
|
+
properties: {
|
|
407
|
+
object: { type: "string", const: "card" },
|
|
408
|
+
name: { type: "string" },
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: {
|
|
414
|
+
object: { type: "string", const: "bank_account" },
|
|
415
|
+
name: { type: "string" },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
"response",
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
expect(result.oneOf).toHaveLength(2);
|
|
424
|
+
// const should pass through (standard JSON Schema keyword)
|
|
425
|
+
const branches = result.oneOf as Record<string, unknown>[];
|
|
426
|
+
const branch0Props = (branches[0] as Record<string, unknown>)
|
|
427
|
+
.properties as Record<string, unknown>;
|
|
428
|
+
const obj0 = branch0Props.object as Record<string, unknown>;
|
|
429
|
+
expect(obj0.const).toBe("card");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("handles additionalProperties: true (free-form)", () => {
|
|
433
|
+
const result = normalizeSchema(
|
|
434
|
+
{ type: "object", additionalProperties: true },
|
|
435
|
+
"response",
|
|
436
|
+
);
|
|
437
|
+
expect(result.additionalProperties).toBe(true);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("handles additionalProperties as schema with nullable", () => {
|
|
441
|
+
const result = normalizeSchema(
|
|
442
|
+
{
|
|
443
|
+
type: "object",
|
|
444
|
+
additionalProperties: { type: "string", nullable: true },
|
|
445
|
+
},
|
|
446
|
+
"response",
|
|
447
|
+
);
|
|
448
|
+
const ap = result.additionalProperties;
|
|
449
|
+
expect(typeof ap === "object" && ap !== null && "oneOf" in ap).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("does not mutate the input schema", () => {
|
|
453
|
+
const input = {
|
|
454
|
+
type: "string",
|
|
455
|
+
nullable: true,
|
|
456
|
+
"x-custom": "value",
|
|
457
|
+
};
|
|
458
|
+
const copy = JSON.parse(JSON.stringify(input));
|
|
459
|
+
normalizeSchema(input, "response");
|
|
460
|
+
expect(input).toEqual(copy);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("handles empty schema", () => {
|
|
464
|
+
expect(normalizeSchema({}, "response")).toEqual({});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("handles deeply nested: allOf → properties → oneOf → nullable", () => {
|
|
468
|
+
const result = normalizeSchema(
|
|
469
|
+
{
|
|
470
|
+
allOf: [
|
|
471
|
+
{
|
|
472
|
+
type: "object",
|
|
473
|
+
properties: {
|
|
474
|
+
source: {
|
|
475
|
+
oneOf: [
|
|
476
|
+
{
|
|
477
|
+
type: "object",
|
|
478
|
+
properties: {
|
|
479
|
+
cvc: { type: "string", writeOnly: true },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
type: "object",
|
|
484
|
+
properties: {
|
|
485
|
+
sortCode: { type: "string", nullable: true },
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
"response",
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const allOf = result.allOf as Record<string, unknown>[];
|
|
498
|
+
const props = allOf[0].properties as Record<string, unknown>;
|
|
499
|
+
const source = props.source as Record<string, unknown>;
|
|
500
|
+
const branches = source.oneOf as Record<string, unknown>[];
|
|
501
|
+
|
|
502
|
+
// writeOnly cvc stripped from response
|
|
503
|
+
const cardProps = branches[0].properties as Record<string, unknown>;
|
|
504
|
+
expect(cardProps).not.toHaveProperty("cvc");
|
|
505
|
+
|
|
506
|
+
// nullable sortCode → oneOf
|
|
507
|
+
const bankProps = branches[1].properties as Record<string, unknown>;
|
|
508
|
+
const sortCode = bankProps.sortCode as Record<string, unknown>;
|
|
509
|
+
expect(sortCode.oneOf).toBeDefined();
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ════════════════════════════════════════════════════════════════════
|
|
514
|
+
// 5. CRUD DETECTOR — edge cases
|
|
515
|
+
// ════════════════════════════════════════════════════════════════════
|
|
516
|
+
describe("stress: crud-detector edge cases", () => {
|
|
517
|
+
it("single-POST-only endpoints are non-CRUD", () => {
|
|
518
|
+
const result = detectCrudResources([
|
|
519
|
+
{
|
|
520
|
+
path: "/webhook",
|
|
521
|
+
method: "POST",
|
|
522
|
+
parameters: [],
|
|
523
|
+
responses: new Map(),
|
|
524
|
+
tags: [],
|
|
525
|
+
},
|
|
526
|
+
]);
|
|
527
|
+
expect(result.resources).toHaveLength(0);
|
|
528
|
+
expect(result.nonCrudPaths).toHaveLength(1);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("deeply nested resource paths", () => {
|
|
532
|
+
const result = detectCrudResources([
|
|
533
|
+
{
|
|
534
|
+
path: "/org/:orgId/team/:teamId/members",
|
|
535
|
+
method: "GET",
|
|
536
|
+
parameters: [],
|
|
537
|
+
responses: new Map(),
|
|
538
|
+
tags: [],
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
path: "/org/:orgId/team/:teamId/members",
|
|
542
|
+
method: "POST",
|
|
543
|
+
parameters: [],
|
|
544
|
+
responses: new Map(),
|
|
545
|
+
tags: [],
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
path: "/org/:orgId/team/:teamId/members/:memberId",
|
|
549
|
+
method: "GET",
|
|
550
|
+
parameters: [],
|
|
551
|
+
responses: new Map(),
|
|
552
|
+
tags: [],
|
|
553
|
+
},
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
expect(result.resources).toHaveLength(1);
|
|
557
|
+
expect(result.resources[0].name).toBe("members");
|
|
558
|
+
expect(result.resources[0].basePath).toBe(
|
|
559
|
+
"/org/:orgId/team/:teamId/members",
|
|
560
|
+
);
|
|
561
|
+
expect(result.resources[0].idParam).toBe("memberId");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("root path / is non-CRUD", () => {
|
|
565
|
+
const result = detectCrudResources([
|
|
566
|
+
{
|
|
567
|
+
path: "/",
|
|
568
|
+
method: "GET",
|
|
569
|
+
parameters: [],
|
|
570
|
+
responses: new Map(),
|
|
571
|
+
tags: [],
|
|
572
|
+
},
|
|
573
|
+
]);
|
|
574
|
+
expect(result.resources).toHaveLength(0);
|
|
575
|
+
expect(result.nonCrudPaths).toHaveLength(1);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("GET-only collection is non-CRUD", () => {
|
|
579
|
+
const result = detectCrudResources([
|
|
580
|
+
{
|
|
581
|
+
path: "/readonly-things",
|
|
582
|
+
method: "GET",
|
|
583
|
+
parameters: [],
|
|
584
|
+
responses: new Map(),
|
|
585
|
+
tags: [],
|
|
586
|
+
},
|
|
587
|
+
]);
|
|
588
|
+
expect(result.resources).toHaveLength(0);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// ════════════════════════════════════════════════════════════════════
|
|
593
|
+
// 6. GENERATOR STRESS — via plugin integration
|
|
594
|
+
// ════════════════════════════════════════════════════════════════════
|
|
595
|
+
describe("stress: generators via plugin", () => {
|
|
596
|
+
it("rapid sequential creates (100 items)", async () => {
|
|
597
|
+
const mock = schmock({ state: {} });
|
|
598
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
599
|
+
|
|
600
|
+
for (let i = 0; i < 100; i++) {
|
|
601
|
+
const res = await mock.handle("POST", "/pets", {
|
|
602
|
+
body: { name: `Pet-${i}` },
|
|
603
|
+
});
|
|
604
|
+
expect(res.status).toBe(201);
|
|
605
|
+
const body = res.body as Record<string, unknown>;
|
|
606
|
+
expect(body.petId).toBe(i + 1);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const list = await mock.handle("GET", "/pets");
|
|
610
|
+
expect(list.body).toHaveLength(100);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("create with empty body", async () => {
|
|
614
|
+
const mock = schmock({ state: {} });
|
|
615
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
616
|
+
|
|
617
|
+
const res = await mock.handle("POST", "/pets");
|
|
618
|
+
expect(res.status).toBe(201);
|
|
619
|
+
const body = res.body as Record<string, unknown>;
|
|
620
|
+
expect(body.petId).toBe(1);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("update with empty body preserves existing", async () => {
|
|
624
|
+
const mock = schmock({ state: {} });
|
|
625
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
626
|
+
|
|
627
|
+
await mock.handle("POST", "/pets", {
|
|
628
|
+
body: { name: "Buddy", tag: "dog" },
|
|
629
|
+
});
|
|
630
|
+
const updated = await mock.handle("PUT", "/pets/1");
|
|
631
|
+
expect(updated.status).toBe(200);
|
|
632
|
+
const body = updated.body as Record<string, unknown>;
|
|
633
|
+
expect(body.name).toBe("Buddy");
|
|
634
|
+
expect(body.petId).toBe(1);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("update cannot overwrite ID", async () => {
|
|
638
|
+
const mock = schmock({ state: {} });
|
|
639
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
640
|
+
|
|
641
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
642
|
+
const updated = await mock.handle("PUT", "/pets/1", {
|
|
643
|
+
body: { name: "Max", petId: 999 },
|
|
644
|
+
});
|
|
645
|
+
const body = updated.body as Record<string, unknown>;
|
|
646
|
+
expect(body.petId).toBe(1);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("double-delete returns 404", async () => {
|
|
650
|
+
const mock = schmock({ state: {} });
|
|
651
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
652
|
+
|
|
653
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
654
|
+
await mock.handle("DELETE", "/pets/1");
|
|
655
|
+
const second = await mock.handle("DELETE", "/pets/1");
|
|
656
|
+
expect(second.status).toBe(404);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("create-delete-list: only even IDs survive", async () => {
|
|
660
|
+
const mock = schmock({ state: {} });
|
|
661
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
662
|
+
|
|
663
|
+
for (let i = 0; i < 10; i++) {
|
|
664
|
+
await mock.handle("POST", "/pets", { body: { name: `Pet-${i}` } });
|
|
665
|
+
}
|
|
666
|
+
for (let i = 1; i <= 10; i += 2) {
|
|
667
|
+
await mock.handle("DELETE", `/pets/${i}`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const list = await mock.handle("GET", "/pets");
|
|
671
|
+
const items = list.body as Record<string, unknown>[];
|
|
672
|
+
expect(items).toHaveLength(5);
|
|
673
|
+
for (const item of items) {
|
|
674
|
+
expect((item.petId as number) % 2).toBe(0);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("resetState clears CRUD collections", async () => {
|
|
679
|
+
const mock = schmock({ state: {} });
|
|
680
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
681
|
+
|
|
682
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
683
|
+
expect((await mock.handle("GET", "/pets")).body).toHaveLength(1);
|
|
684
|
+
|
|
685
|
+
mock.resetState();
|
|
686
|
+
|
|
687
|
+
const after = await mock.handle("GET", "/pets");
|
|
688
|
+
expect(after.body).toEqual([]);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("string IDs work with seed data", async () => {
|
|
692
|
+
const mock = schmock({ state: {} });
|
|
693
|
+
mock.pipe(
|
|
694
|
+
await openapi({
|
|
695
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
696
|
+
seed: { pets: [{ petId: "abc-123", name: "Luna" }] },
|
|
697
|
+
}),
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const read = await mock.handle("GET", "/pets/abc-123");
|
|
701
|
+
expect(read.status).toBe(200);
|
|
702
|
+
expect(read.body).toMatchObject({ name: "Luna" });
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ════════════════════════════════════════════════════════════════════
|
|
707
|
+
// 7. SEED STRESS
|
|
708
|
+
// ════════════════════════════════════════════════════════════════════
|
|
709
|
+
describe("stress: seed data", () => {
|
|
710
|
+
it("large inline dataset (200 items)", async () => {
|
|
711
|
+
const items = Array.from({ length: 200 }, (_, i) => ({
|
|
712
|
+
petId: i + 1,
|
|
713
|
+
name: `Pet-${i + 1}`,
|
|
714
|
+
}));
|
|
715
|
+
|
|
716
|
+
const mock = schmock({ state: {} });
|
|
717
|
+
mock.pipe(
|
|
718
|
+
await openapi({
|
|
719
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
720
|
+
seed: { pets: items },
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
const list = await mock.handle("GET", "/pets");
|
|
725
|
+
expect(list.body).toHaveLength(200);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("auto-increment continues after seed", async () => {
|
|
729
|
+
const mock = schmock({ state: {} });
|
|
730
|
+
mock.pipe(
|
|
731
|
+
await openapi({
|
|
732
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
733
|
+
seed: {
|
|
734
|
+
pets: Array.from({ length: 50 }, (_, i) => ({
|
|
735
|
+
petId: i + 1,
|
|
736
|
+
name: `Pet-${i + 1}`,
|
|
737
|
+
})),
|
|
738
|
+
},
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
const created = await mock.handle("POST", "/pets", {
|
|
743
|
+
body: { name: "New" },
|
|
744
|
+
});
|
|
745
|
+
const body = created.body as Record<string, unknown>;
|
|
746
|
+
expect(body.petId).toBe(51);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("non-sequential IDs: max is picked correctly", async () => {
|
|
750
|
+
const mock = schmock({ state: {} });
|
|
751
|
+
mock.pipe(
|
|
752
|
+
await openapi({
|
|
753
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
754
|
+
seed: {
|
|
755
|
+
pets: [
|
|
756
|
+
{ petId: 10, name: "A" },
|
|
757
|
+
{ petId: 5, name: "B" },
|
|
758
|
+
{ petId: 100, name: "C" },
|
|
759
|
+
],
|
|
760
|
+
},
|
|
761
|
+
}),
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
const created = await mock.handle("POST", "/pets", {
|
|
765
|
+
body: { name: "New" },
|
|
766
|
+
});
|
|
767
|
+
const body = created.body as Record<string, unknown>;
|
|
768
|
+
expect(body.petId).toBe(101);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// ════════════════════════════════════════════════════════════════════
|
|
773
|
+
// 8. PLUGIN EDGE CASES
|
|
774
|
+
// ════════════════════════════════════════════════════════════════════
|
|
775
|
+
describe("stress: plugin edge cases", () => {
|
|
776
|
+
it("all-static spec (no CRUD resources)", async () => {
|
|
777
|
+
const mock = schmock({ state: {} });
|
|
778
|
+
mock.pipe(
|
|
779
|
+
await openapi({
|
|
780
|
+
spec: {
|
|
781
|
+
openapi: "3.0.3",
|
|
782
|
+
info: { title: "Static", version: "1.0.0" },
|
|
783
|
+
paths: {
|
|
784
|
+
"/health": {
|
|
785
|
+
get: {
|
|
786
|
+
responses: {
|
|
787
|
+
"200": {
|
|
788
|
+
description: "OK",
|
|
789
|
+
content: {
|
|
790
|
+
"application/json": {
|
|
791
|
+
schema: {
|
|
792
|
+
type: "object",
|
|
793
|
+
properties: { status: { type: "string" } },
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
}),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const health = await mock.handle("GET", "/health");
|
|
807
|
+
expect(health.status).toBe(200);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("multiple openapi plugins coexist", async () => {
|
|
811
|
+
const mock = schmock({ state: {} });
|
|
812
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
813
|
+
mock.pipe(
|
|
814
|
+
await openapi({
|
|
815
|
+
spec: {
|
|
816
|
+
openapi: "3.0.3",
|
|
817
|
+
info: { title: "Users", version: "1.0.0" },
|
|
818
|
+
paths: {
|
|
819
|
+
"/users": {
|
|
820
|
+
get: {
|
|
821
|
+
responses: {
|
|
822
|
+
"200": {
|
|
823
|
+
description: "List",
|
|
824
|
+
content: {
|
|
825
|
+
"application/json": {
|
|
826
|
+
schema: {
|
|
827
|
+
type: "array",
|
|
828
|
+
items: {
|
|
829
|
+
type: "object",
|
|
830
|
+
properties: { userId: { type: "integer" } },
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
post: {
|
|
839
|
+
responses: { "201": { description: "Created" } },
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
}),
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
848
|
+
await mock.handle("POST", "/users", { body: { name: "Alice" } });
|
|
849
|
+
|
|
850
|
+
expect((await mock.handle("GET", "/pets")).body).toHaveLength(1);
|
|
851
|
+
expect((await mock.handle("GET", "/users")).body).toHaveLength(1);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("unregistered routes return 404", async () => {
|
|
855
|
+
const mock = schmock({ state: {} });
|
|
856
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
857
|
+
|
|
858
|
+
const res = await mock.handle("GET", "/nonexistent");
|
|
859
|
+
expect(res.status).toBe(404);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it("spec with no response schemas returns empty object", async () => {
|
|
863
|
+
const mock = schmock({ state: {} });
|
|
864
|
+
mock.pipe(
|
|
865
|
+
await openapi({
|
|
866
|
+
spec: {
|
|
867
|
+
openapi: "3.0.3",
|
|
868
|
+
info: { title: "NoSchema", version: "1.0.0" },
|
|
869
|
+
paths: {
|
|
870
|
+
"/ping": {
|
|
871
|
+
get: {
|
|
872
|
+
responses: { "204": { description: "No Content" } },
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
}),
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
const ping = await mock.handle("GET", "/ping");
|
|
881
|
+
expect(ping.status).toBe(200);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// ════════════════════════════════════════════════════════════════════
|
|
886
|
+
// 9. PARSER EDGE CASES
|
|
887
|
+
// ════════════════════════════════════════════════════════════════════
|
|
888
|
+
describe("stress: parser edge cases", () => {
|
|
889
|
+
it("empty paths object", async () => {
|
|
890
|
+
const spec = await parseSpec({
|
|
891
|
+
openapi: "3.0.3",
|
|
892
|
+
info: { title: "Empty", version: "0.0.0" },
|
|
893
|
+
paths: {},
|
|
894
|
+
});
|
|
895
|
+
expect(spec.paths).toEqual([]);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("no paths key at all", async () => {
|
|
899
|
+
const spec = await parseSpec({
|
|
900
|
+
openapi: "3.0.3",
|
|
901
|
+
info: { title: "NoPaths", version: "0.0.0" },
|
|
902
|
+
});
|
|
903
|
+
expect(spec.paths).toEqual([]);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("servers with relative path", async () => {
|
|
907
|
+
const spec = await parseSpec({
|
|
908
|
+
openapi: "3.0.3",
|
|
909
|
+
info: { title: "Rel", version: "0.0.0" },
|
|
910
|
+
servers: [{ url: "/v3" }],
|
|
911
|
+
paths: {},
|
|
912
|
+
});
|
|
913
|
+
expect(spec.basePath).toBe("/v3");
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("servers with root path only", async () => {
|
|
917
|
+
const spec = await parseSpec({
|
|
918
|
+
openapi: "3.0.3",
|
|
919
|
+
info: { title: "Root", version: "0.0.0" },
|
|
920
|
+
servers: [{ url: "/" }],
|
|
921
|
+
paths: {},
|
|
922
|
+
});
|
|
923
|
+
expect(spec.basePath).toBe("");
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("Swagger 2.0 basePath is extracted", async () => {
|
|
927
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
928
|
+
expect(spec.basePath).toBe("/api");
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// ════════════════════════════════════════════════════════════════════
|
|
933
|
+
// 10. SCALAR GALAXY — OpenAPI 3.1.1 BREAD stress
|
|
934
|
+
// ════════════════════════════════════════════════════════════════════
|
|
935
|
+
|
|
936
|
+
const scalarGalaxySpec = resolve(fixturesDir, "scalar-galaxy.yaml");
|
|
937
|
+
|
|
938
|
+
// Real solar system seed data — planetId matches the CRUD resource's idParam
|
|
939
|
+
const solarSystemPlanets = [
|
|
940
|
+
{
|
|
941
|
+
planetId: 1,
|
|
942
|
+
name: "Mercury",
|
|
943
|
+
type: "terrestrial",
|
|
944
|
+
habitabilityIndex: 0.01,
|
|
945
|
+
physicalProperties: { mass: 0.055, radius: 0.383, gravity: 0.38 },
|
|
946
|
+
atmosphere: [],
|
|
947
|
+
tags: ["solar-system", "rocky", "inner"],
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
planetId: 2,
|
|
951
|
+
name: "Venus",
|
|
952
|
+
type: "terrestrial",
|
|
953
|
+
habitabilityIndex: 0.04,
|
|
954
|
+
physicalProperties: { mass: 0.815, radius: 0.95, gravity: 0.9 },
|
|
955
|
+
atmosphere: [{ compound: "CO2", percentage: 96.5 }],
|
|
956
|
+
tags: ["solar-system", "rocky", "inner"],
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
planetId: 3,
|
|
960
|
+
name: "Earth",
|
|
961
|
+
type: "terrestrial",
|
|
962
|
+
habitabilityIndex: 1.0,
|
|
963
|
+
physicalProperties: { mass: 1.0, radius: 1.0, gravity: 1.0 },
|
|
964
|
+
atmosphere: [
|
|
965
|
+
{ compound: "N2", percentage: 78.1 },
|
|
966
|
+
{ compound: "O2", percentage: 20.9 },
|
|
967
|
+
],
|
|
968
|
+
tags: ["solar-system", "rocky", "habitable"],
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
planetId: 4,
|
|
972
|
+
name: "Mars",
|
|
973
|
+
type: "terrestrial",
|
|
974
|
+
habitabilityIndex: 0.68,
|
|
975
|
+
physicalProperties: { mass: 0.107, radius: 0.532, gravity: 0.378 },
|
|
976
|
+
atmosphere: [{ compound: "CO2", percentage: 95.3 }],
|
|
977
|
+
tags: ["solar-system", "rocky", "explored"],
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
planetId: 5,
|
|
981
|
+
name: "Jupiter",
|
|
982
|
+
type: "gas_giant",
|
|
983
|
+
habitabilityIndex: 0.0,
|
|
984
|
+
physicalProperties: { mass: 317.8, radius: 11.21, gravity: 2.53 },
|
|
985
|
+
atmosphere: [
|
|
986
|
+
{ compound: "H2", percentage: 89.8 },
|
|
987
|
+
{ compound: "He", percentage: 10.2 },
|
|
988
|
+
],
|
|
989
|
+
tags: ["solar-system", "gas", "outer"],
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
planetId: 6,
|
|
993
|
+
name: "Saturn",
|
|
994
|
+
type: "gas_giant",
|
|
995
|
+
habitabilityIndex: 0.0,
|
|
996
|
+
physicalProperties: { mass: 95.16, radius: 9.45, gravity: 1.065 },
|
|
997
|
+
atmosphere: [
|
|
998
|
+
{ compound: "H2", percentage: 96.3 },
|
|
999
|
+
{ compound: "He", percentage: 3.25 },
|
|
1000
|
+
],
|
|
1001
|
+
tags: ["solar-system", "gas", "ringed"],
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
planetId: 7,
|
|
1005
|
+
name: "Uranus",
|
|
1006
|
+
type: "ice_giant",
|
|
1007
|
+
habitabilityIndex: 0.0,
|
|
1008
|
+
physicalProperties: { mass: 14.54, radius: 4.01, gravity: 0.886 },
|
|
1009
|
+
atmosphere: [
|
|
1010
|
+
{ compound: "H2", percentage: 82.5 },
|
|
1011
|
+
{ compound: "He", percentage: 15.2 },
|
|
1012
|
+
],
|
|
1013
|
+
tags: ["solar-system", "ice", "outer"],
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
planetId: 8,
|
|
1017
|
+
name: "Neptune",
|
|
1018
|
+
type: "ice_giant",
|
|
1019
|
+
habitabilityIndex: 0.0,
|
|
1020
|
+
physicalProperties: { mass: 17.15, radius: 3.88, gravity: 1.14 },
|
|
1021
|
+
atmosphere: [
|
|
1022
|
+
{ compound: "H2", percentage: 80.0 },
|
|
1023
|
+
{ compound: "He", percentage: 19.0 },
|
|
1024
|
+
],
|
|
1025
|
+
tags: ["solar-system", "ice", "outer"],
|
|
1026
|
+
},
|
|
1027
|
+
];
|
|
1028
|
+
|
|
1029
|
+
describe("stress: scalar-galaxy.yaml — parser", () => {
|
|
1030
|
+
it("parses the OpenAPI 3.1.1 Scalar Galaxy spec", async () => {
|
|
1031
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1032
|
+
expect(spec.title).toBe("Scalar Galaxy");
|
|
1033
|
+
expect(spec.version).toBe("0.5.12");
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("strips x-speakeasy-webhooks and x-scalar-* extensions", async () => {
|
|
1037
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1038
|
+
for (const p of spec.paths) {
|
|
1039
|
+
if (p.requestBody) {
|
|
1040
|
+
const str = JSON.stringify(p.requestBody);
|
|
1041
|
+
expect(str).not.toContain('"x-');
|
|
1042
|
+
}
|
|
1043
|
+
for (const [, resp] of p.responses) {
|
|
1044
|
+
if (resp.schema) {
|
|
1045
|
+
const str = JSON.stringify(resp.schema);
|
|
1046
|
+
expect(str).not.toContain('"x-');
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it("extracts all planet routes", async () => {
|
|
1053
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1054
|
+
const sigs = spec.paths.map((p) => `${p.method} ${p.path}`);
|
|
1055
|
+
expect(sigs).toContain("GET /planets");
|
|
1056
|
+
expect(sigs).toContain("POST /planets");
|
|
1057
|
+
expect(sigs).toContain("GET /planets/:planetId");
|
|
1058
|
+
expect(sigs).toContain("PUT /planets/:planetId");
|
|
1059
|
+
expect(sigs).toContain("DELETE /planets/:planetId");
|
|
1060
|
+
expect(sigs).toContain("POST /planets/:planetId/image");
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("extracts auth routes", async () => {
|
|
1064
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1065
|
+
const sigs = spec.paths.map((p) => `${p.method} ${p.path}`);
|
|
1066
|
+
expect(sigs).toContain("POST /user/signup");
|
|
1067
|
+
expect(sigs).toContain("POST /auth/token");
|
|
1068
|
+
expect(sigs).toContain("GET /me");
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it("extracts $ref'd parameters (limit, offset, planetId)", async () => {
|
|
1072
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1073
|
+
const listPlanets = spec.paths.find(
|
|
1074
|
+
(p) => p.method === "GET" && p.path === "/planets",
|
|
1075
|
+
);
|
|
1076
|
+
const paramNames = listPlanets?.parameters.map((p) => p.name) ?? [];
|
|
1077
|
+
expect(paramNames).toContain("limit");
|
|
1078
|
+
expect(paramNames).toContain("offset");
|
|
1079
|
+
|
|
1080
|
+
const getPlanet = spec.paths.find(
|
|
1081
|
+
(p) => p.method === "GET" && p.path === "/planets/:planetId",
|
|
1082
|
+
);
|
|
1083
|
+
const itemParamNames = getPlanet?.parameters.map((p) => p.name) ?? [];
|
|
1084
|
+
expect(itemParamNames).toContain("planetId");
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it("extracts Planet schema with nested physicalProperties", async () => {
|
|
1088
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1089
|
+
const getPlanet = spec.paths.find(
|
|
1090
|
+
(p) => p.method === "GET" && p.path === "/planets/:planetId",
|
|
1091
|
+
);
|
|
1092
|
+
const schema = getPlanet?.responses.get(200)?.schema;
|
|
1093
|
+
expect(schema).toBeDefined();
|
|
1094
|
+
expect(schema?.properties).toHaveProperty("name");
|
|
1095
|
+
expect(schema?.properties).toHaveProperty("physicalProperties");
|
|
1096
|
+
expect(schema?.properties).toHaveProperty("atmosphere");
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it("strips readOnly fields (id, lastUpdated) from request schemas", async () => {
|
|
1100
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1101
|
+
const createPlanet = spec.paths.find(
|
|
1102
|
+
(p) => p.method === "POST" && p.path === "/planets",
|
|
1103
|
+
);
|
|
1104
|
+
expect(createPlanet?.requestBody).toBeDefined();
|
|
1105
|
+
// id and lastUpdated are readOnly — should be stripped from request
|
|
1106
|
+
expect(createPlanet?.requestBody?.properties).not.toHaveProperty("id");
|
|
1107
|
+
expect(createPlanet?.requestBody?.properties).not.toHaveProperty(
|
|
1108
|
+
"lastUpdated",
|
|
1109
|
+
);
|
|
1110
|
+
// name should remain
|
|
1111
|
+
expect(createPlanet?.requestBody?.properties).toHaveProperty("name");
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
describe("stress: scalar-galaxy.yaml — CRUD detection", () => {
|
|
1116
|
+
it("detects planets as a full CRUD resource", async () => {
|
|
1117
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1118
|
+
const result = detectCrudResources(spec.paths);
|
|
1119
|
+
const planets = result.resources.find((r) => r.name === "planets");
|
|
1120
|
+
expect(planets).toBeDefined();
|
|
1121
|
+
expect(planets?.basePath).toBe("/planets");
|
|
1122
|
+
expect(planets?.itemPath).toBe("/planets/:planetId");
|
|
1123
|
+
expect(planets?.idParam).toBe("planetId");
|
|
1124
|
+
expect(planets?.operations).toContain("list");
|
|
1125
|
+
expect(planets?.operations).toContain("create");
|
|
1126
|
+
expect(planets?.operations).toContain("read");
|
|
1127
|
+
expect(planets?.operations).toContain("update");
|
|
1128
|
+
expect(planets?.operations).toContain("delete");
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it("classifies non-CRUD paths correctly", async () => {
|
|
1132
|
+
const spec = await parseSpec(scalarGalaxySpec);
|
|
1133
|
+
const result = detectCrudResources(spec.paths);
|
|
1134
|
+
const nonCrudPaths = result.nonCrudPaths.map((p) => p.path);
|
|
1135
|
+
expect(nonCrudPaths).toContain("/planets/:planetId/image");
|
|
1136
|
+
expect(nonCrudPaths).toContain("/user/signup");
|
|
1137
|
+
expect(nonCrudPaths).toContain("/auth/token");
|
|
1138
|
+
expect(nonCrudPaths).toContain("/me");
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
describe("stress: scalar-galaxy.yaml — BREAD operations", () => {
|
|
1143
|
+
it("full solar system lifecycle: seed → browse → read → add → edit → delete", async () => {
|
|
1144
|
+
const mock = schmock({ state: {} });
|
|
1145
|
+
mock.pipe(
|
|
1146
|
+
await openapi({
|
|
1147
|
+
spec: scalarGalaxySpec,
|
|
1148
|
+
seed: { planets: solarSystemPlanets },
|
|
1149
|
+
}),
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
// BROWSE — list all 8 planets
|
|
1153
|
+
const allPlanets = await mock.handle("GET", "/planets");
|
|
1154
|
+
expect(allPlanets.status).toBe(200);
|
|
1155
|
+
expect(allPlanets.body).toHaveLength(8);
|
|
1156
|
+
|
|
1157
|
+
// READ — each planet is coherent
|
|
1158
|
+
for (let i = 1; i <= 8; i++) {
|
|
1159
|
+
const planet = await mock.handle("GET", `/planets/${i}`);
|
|
1160
|
+
expect(planet.status).toBe(200);
|
|
1161
|
+
const body = planet.body as Record<string, unknown>;
|
|
1162
|
+
expect(body.name).toBe(solarSystemPlanets[i - 1].name);
|
|
1163
|
+
expect(body.type).toBe(solarSystemPlanets[i - 1].type);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// READ specific — Earth is habitable
|
|
1167
|
+
const earth = await mock.handle("GET", "/planets/3");
|
|
1168
|
+
const earthBody = earth.body as Record<string, unknown>;
|
|
1169
|
+
expect(earthBody.name).toBe("Earth");
|
|
1170
|
+
expect(earthBody.habitabilityIndex).toBe(1.0);
|
|
1171
|
+
expect(earthBody.type).toBe("terrestrial");
|
|
1172
|
+
|
|
1173
|
+
// ADD — discover a new planet
|
|
1174
|
+
const created = await mock.handle("POST", "/planets", {
|
|
1175
|
+
body: {
|
|
1176
|
+
name: "Kepler-442b",
|
|
1177
|
+
type: "super_earth",
|
|
1178
|
+
habitabilityIndex: 0.84,
|
|
1179
|
+
physicalProperties: { mass: 2.36, radius: 1.34, gravity: 1.31 },
|
|
1180
|
+
atmosphere: [{ compound: "N2", percentage: 70.0 }],
|
|
1181
|
+
tags: ["exoplanet", "habitable-zone"],
|
|
1182
|
+
},
|
|
1183
|
+
});
|
|
1184
|
+
expect(created.status).toBe(201);
|
|
1185
|
+
const newPlanet = created.body as Record<string, unknown>;
|
|
1186
|
+
expect(newPlanet.name).toBe("Kepler-442b");
|
|
1187
|
+
expect(newPlanet.planetId).toBe(9); // auto-incremented past seed max (8)
|
|
1188
|
+
|
|
1189
|
+
// BROWSE after ADD — 9 planets
|
|
1190
|
+
const afterAdd = await mock.handle("GET", "/planets");
|
|
1191
|
+
expect(afterAdd.body).toHaveLength(9);
|
|
1192
|
+
|
|
1193
|
+
// EDIT — update Mars terraforming progress
|
|
1194
|
+
const edited = await mock.handle("PUT", "/planets/4", {
|
|
1195
|
+
body: { habitabilityIndex: 0.75, tags: ["solar-system", "terraformed"] },
|
|
1196
|
+
});
|
|
1197
|
+
expect(edited.status).toBe(200);
|
|
1198
|
+
const marsUpdated = edited.body as Record<string, unknown>;
|
|
1199
|
+
expect(marsUpdated.name).toBe("Mars"); // preserved
|
|
1200
|
+
expect(marsUpdated.habitabilityIndex).toBe(0.75); // updated
|
|
1201
|
+
|
|
1202
|
+
// DELETE — Pluto was never a planet anyway
|
|
1203
|
+
const deleted = await mock.handle("DELETE", "/planets/9");
|
|
1204
|
+
expect(deleted.status).toBe(204);
|
|
1205
|
+
|
|
1206
|
+
// BROWSE after DELETE — back to 8
|
|
1207
|
+
const afterDelete = await mock.handle("GET", "/planets");
|
|
1208
|
+
expect(afterDelete.body).toHaveLength(8);
|
|
1209
|
+
|
|
1210
|
+
// READ deleted — 404
|
|
1211
|
+
const gone = await mock.handle("GET", "/planets/9");
|
|
1212
|
+
expect(gone.status).toBe(404);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it("PATCH works for planet updates too", async () => {
|
|
1216
|
+
const mock = schmock({ state: {} });
|
|
1217
|
+
mock.pipe(
|
|
1218
|
+
await openapi({
|
|
1219
|
+
spec: scalarGalaxySpec,
|
|
1220
|
+
seed: {
|
|
1221
|
+
planets: [{ planetId: 1, name: "Mars", type: "terrestrial" }],
|
|
1222
|
+
},
|
|
1223
|
+
}),
|
|
1224
|
+
);
|
|
1225
|
+
|
|
1226
|
+
const patched = await mock.handle("PATCH", "/planets/1", {
|
|
1227
|
+
body: { habitabilityIndex: 0.42 },
|
|
1228
|
+
});
|
|
1229
|
+
expect(patched.status).toBe(200);
|
|
1230
|
+
const body = patched.body as Record<string, unknown>;
|
|
1231
|
+
expect(body.name).toBe("Mars");
|
|
1232
|
+
expect(body.habitabilityIndex).toBe(0.42);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it("solar system physical properties are preserved through CRUD", async () => {
|
|
1236
|
+
const mock = schmock({ state: {} });
|
|
1237
|
+
mock.pipe(
|
|
1238
|
+
await openapi({
|
|
1239
|
+
spec: scalarGalaxySpec,
|
|
1240
|
+
seed: { planets: solarSystemPlanets },
|
|
1241
|
+
}),
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
// Verify nested physicalProperties survive
|
|
1245
|
+
const jupiter = await mock.handle("GET", "/planets/5");
|
|
1246
|
+
const jupiterBody = jupiter.body as Record<string, unknown>;
|
|
1247
|
+
const physics = jupiterBody.physicalProperties as Record<string, unknown>;
|
|
1248
|
+
expect(physics.mass).toBe(317.8);
|
|
1249
|
+
expect(physics.radius).toBe(11.21);
|
|
1250
|
+
expect(physics.gravity).toBe(2.53);
|
|
1251
|
+
|
|
1252
|
+
// Verify atmosphere arrays survive
|
|
1253
|
+
const earth = await mock.handle("GET", "/planets/3");
|
|
1254
|
+
const earthBody = earth.body as Record<string, unknown>;
|
|
1255
|
+
const atmo = earthBody.atmosphere as Array<Record<string, unknown>>;
|
|
1256
|
+
expect(atmo).toHaveLength(2);
|
|
1257
|
+
expect(atmo[0].compound).toBe("N2");
|
|
1258
|
+
expect(atmo[0].percentage).toBe(78.1);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("non-CRUD endpoints work alongside CRUD", async () => {
|
|
1262
|
+
const mock = schmock({ state: {} });
|
|
1263
|
+
mock.pipe(
|
|
1264
|
+
await openapi({
|
|
1265
|
+
spec: scalarGalaxySpec,
|
|
1266
|
+
seed: { planets: solarSystemPlanets },
|
|
1267
|
+
}),
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
// Auth endpoints — non-CRUD static
|
|
1271
|
+
const signup = await mock.handle("POST", "/user/signup", {
|
|
1272
|
+
body: {
|
|
1273
|
+
name: "Astronaut",
|
|
1274
|
+
email: "astro@galaxy.com",
|
|
1275
|
+
password: "s3cr3t",
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
expect(signup.status).toBe(200);
|
|
1279
|
+
|
|
1280
|
+
const token = await mock.handle("POST", "/auth/token", {
|
|
1281
|
+
body: { email: "astro@galaxy.com", password: "s3cr3t" },
|
|
1282
|
+
});
|
|
1283
|
+
expect(token.status).toBe(200);
|
|
1284
|
+
|
|
1285
|
+
const me = await mock.handle("GET", "/me");
|
|
1286
|
+
expect(me.status).toBe(200);
|
|
1287
|
+
|
|
1288
|
+
// Image upload endpoint — non-CRUD static
|
|
1289
|
+
const image = await mock.handle("POST", "/planets/3/image");
|
|
1290
|
+
expect(image.status).toBe(200);
|
|
1291
|
+
|
|
1292
|
+
// Celestial bodies — non-CRUD (no GET collection)
|
|
1293
|
+
const celestial = await mock.handle("POST", "/celestial-bodies", {
|
|
1294
|
+
body: { name: "Phobos", type: "moon" },
|
|
1295
|
+
});
|
|
1296
|
+
expect(celestial.status).toBe(200);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it("gas giants vs terrestrial planets maintain type integrity", async () => {
|
|
1300
|
+
const mock = schmock({ state: {} });
|
|
1301
|
+
mock.pipe(
|
|
1302
|
+
await openapi({
|
|
1303
|
+
spec: scalarGalaxySpec,
|
|
1304
|
+
seed: { planets: solarSystemPlanets },
|
|
1305
|
+
}),
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1308
|
+
// Filter by reading each and checking type
|
|
1309
|
+
const types: Record<string, string[]> = {};
|
|
1310
|
+
for (let i = 1; i <= 8; i++) {
|
|
1311
|
+
const res = await mock.handle("GET", `/planets/${i}`);
|
|
1312
|
+
const body = res.body as Record<string, unknown>;
|
|
1313
|
+
const type = body.type as string;
|
|
1314
|
+
if (!types[type]) types[type] = [];
|
|
1315
|
+
types[type].push(body.name as string);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
expect(types.terrestrial).toEqual(["Mercury", "Venus", "Earth", "Mars"]);
|
|
1319
|
+
expect(types.gas_giant).toEqual(["Jupiter", "Saturn"]);
|
|
1320
|
+
expect(types.ice_giant).toEqual(["Uranus", "Neptune"]);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
it("mass ordering: Jupiter > Saturn > Neptune > Uranus > Earth > Venus > Mars > Mercury", async () => {
|
|
1324
|
+
const mock = schmock({ state: {} });
|
|
1325
|
+
mock.pipe(
|
|
1326
|
+
await openapi({
|
|
1327
|
+
spec: scalarGalaxySpec,
|
|
1328
|
+
seed: { planets: solarSystemPlanets },
|
|
1329
|
+
}),
|
|
1330
|
+
);
|
|
1331
|
+
|
|
1332
|
+
const masses: Array<{ name: string; mass: number }> = [];
|
|
1333
|
+
for (let i = 1; i <= 8; i++) {
|
|
1334
|
+
const res = await mock.handle("GET", `/planets/${i}`);
|
|
1335
|
+
const body = res.body as Record<string, unknown>;
|
|
1336
|
+
const physics = body.physicalProperties as Record<string, unknown>;
|
|
1337
|
+
masses.push({
|
|
1338
|
+
name: body.name as string,
|
|
1339
|
+
mass: physics.mass as number,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const sorted = [...masses].sort((a, b) => b.mass - a.mass);
|
|
1344
|
+
expect(sorted.map((p) => p.name)).toEqual([
|
|
1345
|
+
"Jupiter",
|
|
1346
|
+
"Saturn",
|
|
1347
|
+
"Neptune",
|
|
1348
|
+
"Uranus",
|
|
1349
|
+
"Earth",
|
|
1350
|
+
"Venus",
|
|
1351
|
+
"Mars",
|
|
1352
|
+
"Mercury",
|
|
1353
|
+
]);
|
|
1354
|
+
});
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1358
|
+
// 11. THE CONFUSED DEVELOPER — doing things wrong or out-of-order
|
|
1359
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1360
|
+
describe("stress: confused developer flows", () => {
|
|
1361
|
+
it("reads before any creates — empty collection, not crash", async () => {
|
|
1362
|
+
const mock = schmock({ state: {} });
|
|
1363
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1364
|
+
|
|
1365
|
+
const read = await mock.handle("GET", "/pets/42");
|
|
1366
|
+
expect(read.status).toBe(404);
|
|
1367
|
+
|
|
1368
|
+
const list = await mock.handle("GET", "/pets");
|
|
1369
|
+
expect(list.status).toBe(200);
|
|
1370
|
+
expect(list.body).toEqual([]);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
it("deletes from empty collection — 404, not crash", async () => {
|
|
1374
|
+
const mock = schmock({ state: {} });
|
|
1375
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1376
|
+
|
|
1377
|
+
const res = await mock.handle("DELETE", "/pets/1");
|
|
1378
|
+
expect(res.status).toBe(404);
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it("updates non-existent item — 404", async () => {
|
|
1382
|
+
const mock = schmock({ state: {} });
|
|
1383
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1384
|
+
|
|
1385
|
+
const res = await mock.handle("PUT", "/pets/999", {
|
|
1386
|
+
body: { name: "Ghost" },
|
|
1387
|
+
});
|
|
1388
|
+
expect(res.status).toBe(404);
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
it("creates then reads with wrong ID format (string vs number)", async () => {
|
|
1392
|
+
const mock = schmock({ state: {} });
|
|
1393
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1394
|
+
|
|
1395
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
1396
|
+
// Path params are always strings — "1" should match item with petId: 1
|
|
1397
|
+
const res = await mock.handle("GET", "/pets/1");
|
|
1398
|
+
expect(res.status).toBe(200);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("sends completely irrelevant body fields — they get stored anyway", async () => {
|
|
1402
|
+
const mock = schmock({ state: {} });
|
|
1403
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1404
|
+
|
|
1405
|
+
const created = await mock.handle("POST", "/pets", {
|
|
1406
|
+
body: {
|
|
1407
|
+
name: "Buddy",
|
|
1408
|
+
favoriteFood: "bacon",
|
|
1409
|
+
socialSecurityNumber: "nope",
|
|
1410
|
+
nestedGarbage: { deeply: { nested: { stuff: true } } },
|
|
1411
|
+
},
|
|
1412
|
+
});
|
|
1413
|
+
expect(created.status).toBe(201);
|
|
1414
|
+
const body = created.body as Record<string, unknown>;
|
|
1415
|
+
expect(body.favoriteFood).toBe("bacon");
|
|
1416
|
+
expect(body.nestedGarbage).toEqual({
|
|
1417
|
+
deeply: { nested: { stuff: true } },
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it("creates with body that tries to set the ID — gets overwritten", async () => {
|
|
1422
|
+
const mock = schmock({ state: {} });
|
|
1423
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1424
|
+
|
|
1425
|
+
const res = await mock.handle("POST", "/pets", {
|
|
1426
|
+
body: { petId: 42, name: "Impostor" },
|
|
1427
|
+
});
|
|
1428
|
+
const body = res.body as Record<string, unknown>;
|
|
1429
|
+
// Auto-increment wins over user-supplied ID
|
|
1430
|
+
expect(body.petId).toBe(1);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it("creates with array body instead of object", async () => {
|
|
1434
|
+
const mock = schmock({ state: {} });
|
|
1435
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1436
|
+
|
|
1437
|
+
const res = await mock.handle("POST", "/pets", {
|
|
1438
|
+
body: [1, 2, 3],
|
|
1439
|
+
});
|
|
1440
|
+
// Array body isn't a record — should create item with just the ID
|
|
1441
|
+
expect(res.status).toBe(201);
|
|
1442
|
+
const body = res.body as Record<string, unknown>;
|
|
1443
|
+
expect(body.petId).toBe(1);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it("creates with string body instead of object", async () => {
|
|
1447
|
+
const mock = schmock({ state: {} });
|
|
1448
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1449
|
+
|
|
1450
|
+
const res = await mock.handle("POST", "/pets", {
|
|
1451
|
+
body: "not an object",
|
|
1452
|
+
});
|
|
1453
|
+
expect(res.status).toBe(201);
|
|
1454
|
+
const body = res.body as Record<string, unknown>;
|
|
1455
|
+
expect(body.petId).toBe(1);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("reads with path param that has special characters", async () => {
|
|
1459
|
+
const mock = schmock({ state: {} });
|
|
1460
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1461
|
+
|
|
1462
|
+
const res = await mock.handle("GET", "/pets/hello%20world");
|
|
1463
|
+
expect(res.status).toBe(404);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
it("PATCHes a deleted item — still 404", async () => {
|
|
1467
|
+
const mock = schmock({ state: {} });
|
|
1468
|
+
mock.pipe(
|
|
1469
|
+
await openapi({
|
|
1470
|
+
spec: scalarGalaxySpec,
|
|
1471
|
+
seed: {
|
|
1472
|
+
planets: [{ planetId: 1, name: "Pluto", type: "dwarf" }],
|
|
1473
|
+
},
|
|
1474
|
+
}),
|
|
1475
|
+
);
|
|
1476
|
+
|
|
1477
|
+
await mock.handle("DELETE", "/planets/1");
|
|
1478
|
+
const res = await mock.handle("PATCH", "/planets/1", {
|
|
1479
|
+
body: { name: "Not Pluto" },
|
|
1480
|
+
});
|
|
1481
|
+
expect(res.status).toBe(404);
|
|
1482
|
+
});
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1486
|
+
// 12. THE LIFECYCLE DEVELOPER — reset, rebuild, multi-instance
|
|
1487
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1488
|
+
describe("stress: lifecycle and multi-instance flows", () => {
|
|
1489
|
+
it("resetState then re-seed through requests", async () => {
|
|
1490
|
+
const mock = schmock({ state: {} });
|
|
1491
|
+
mock.pipe(
|
|
1492
|
+
await openapi({
|
|
1493
|
+
spec: scalarGalaxySpec,
|
|
1494
|
+
seed: { planets: solarSystemPlanets },
|
|
1495
|
+
}),
|
|
1496
|
+
);
|
|
1497
|
+
|
|
1498
|
+
expect((await mock.handle("GET", "/planets")).body).toHaveLength(8);
|
|
1499
|
+
|
|
1500
|
+
mock.resetState();
|
|
1501
|
+
|
|
1502
|
+
// After reset, collection is empty — seeder runs again on next request
|
|
1503
|
+
const list = await mock.handle("GET", "/planets");
|
|
1504
|
+
expect(list.body).toHaveLength(8); // re-seeded on access
|
|
1505
|
+
|
|
1506
|
+
// Can still create new items
|
|
1507
|
+
const created = await mock.handle("POST", "/planets", {
|
|
1508
|
+
body: { name: "Planet X" },
|
|
1509
|
+
});
|
|
1510
|
+
expect(created.status).toBe(201);
|
|
1511
|
+
expect((created.body as Record<string, unknown>).planetId).toBe(9);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it("two independent mock instances with the same spec", async () => {
|
|
1515
|
+
const mockA = schmock({ state: {} });
|
|
1516
|
+
const mockB = schmock({ state: {} });
|
|
1517
|
+
|
|
1518
|
+
const plugin = await openapi({
|
|
1519
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
1520
|
+
});
|
|
1521
|
+
// Each gets its own plugin instance
|
|
1522
|
+
mockA.pipe(
|
|
1523
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
1524
|
+
);
|
|
1525
|
+
mockB.pipe(plugin);
|
|
1526
|
+
|
|
1527
|
+
await mockA.handle("POST", "/pets", { body: { name: "Alpha" } });
|
|
1528
|
+
await mockA.handle("POST", "/pets", { body: { name: "Beta" } });
|
|
1529
|
+
await mockB.handle("POST", "/pets", { body: { name: "Gamma" } });
|
|
1530
|
+
|
|
1531
|
+
// Each instance has its own state
|
|
1532
|
+
expect((await mockA.handle("GET", "/pets")).body).toHaveLength(2);
|
|
1533
|
+
expect((await mockB.handle("GET", "/pets")).body).toHaveLength(1);
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
it("resetState then create — IDs restart from 1", async () => {
|
|
1537
|
+
const mock = schmock({ state: {} });
|
|
1538
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1539
|
+
|
|
1540
|
+
await mock.handle("POST", "/pets", { body: { name: "First" } });
|
|
1541
|
+
await mock.handle("POST", "/pets", { body: { name: "Second" } });
|
|
1542
|
+
expect(
|
|
1543
|
+
((await mock.handle("GET", "/pets/2")).body as Record<string, unknown>)
|
|
1544
|
+
.name,
|
|
1545
|
+
).toBe("Second");
|
|
1546
|
+
|
|
1547
|
+
mock.resetState();
|
|
1548
|
+
|
|
1549
|
+
// After reset, new creates start from ID 1 again
|
|
1550
|
+
const created = await mock.handle("POST", "/pets", {
|
|
1551
|
+
body: { name: "Reborn" },
|
|
1552
|
+
});
|
|
1553
|
+
expect((created.body as Record<string, unknown>).petId).toBe(1);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
it("mix seed and runtime data in Scalar Galaxy", async () => {
|
|
1557
|
+
const mock = schmock({ state: {} });
|
|
1558
|
+
mock.pipe(
|
|
1559
|
+
await openapi({
|
|
1560
|
+
spec: scalarGalaxySpec,
|
|
1561
|
+
seed: {
|
|
1562
|
+
planets: [
|
|
1563
|
+
{ planetId: 1, name: "Earth", type: "terrestrial" },
|
|
1564
|
+
{ planetId: 2, name: "Mars", type: "terrestrial" },
|
|
1565
|
+
],
|
|
1566
|
+
},
|
|
1567
|
+
}),
|
|
1568
|
+
);
|
|
1569
|
+
|
|
1570
|
+
// Runtime-created planets get IDs continuing from seed max
|
|
1571
|
+
await mock.handle("POST", "/planets", { body: { name: "Exo-1" } });
|
|
1572
|
+
await mock.handle("POST", "/planets", { body: { name: "Exo-2" } });
|
|
1573
|
+
|
|
1574
|
+
const list = await mock.handle("GET", "/planets");
|
|
1575
|
+
const items = list.body as Record<string, unknown>[];
|
|
1576
|
+
expect(items).toHaveLength(4);
|
|
1577
|
+
|
|
1578
|
+
// Seed items at IDs 1-2, runtime items at IDs 3-4
|
|
1579
|
+
expect(items[2].planetId).toBe(3);
|
|
1580
|
+
expect(items[3].planetId).toBe(4);
|
|
1581
|
+
expect(items[2].name).toBe("Exo-1");
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it("cross-spec: petstore + galaxy on same instance", async () => {
|
|
1585
|
+
const mock = schmock({ state: {} });
|
|
1586
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1587
|
+
mock.pipe(
|
|
1588
|
+
await openapi({
|
|
1589
|
+
spec: scalarGalaxySpec,
|
|
1590
|
+
seed: { planets: solarSystemPlanets },
|
|
1591
|
+
}),
|
|
1592
|
+
);
|
|
1593
|
+
|
|
1594
|
+
// Petstore CRUD
|
|
1595
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
1596
|
+
expect((await mock.handle("GET", "/pets")).body).toHaveLength(1);
|
|
1597
|
+
|
|
1598
|
+
// Galaxy CRUD — completely independent
|
|
1599
|
+
expect((await mock.handle("GET", "/planets")).body).toHaveLength(8);
|
|
1600
|
+
await mock.handle("POST", "/planets", { body: { name: "Nibiru" } });
|
|
1601
|
+
expect((await mock.handle("GET", "/planets")).body).toHaveLength(9);
|
|
1602
|
+
|
|
1603
|
+
// Petstore still has 1
|
|
1604
|
+
expect((await mock.handle("GET", "/pets")).body).toHaveLength(1);
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1609
|
+
// 13. THE CHAOS MONKEY — rapid fire, edge IDs, bulk operations
|
|
1610
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1611
|
+
describe("stress: chaos monkey flows", () => {
|
|
1612
|
+
it("create-delete-create-read cycle — IDs keep incrementing", async () => {
|
|
1613
|
+
const mock = schmock({ state: {} });
|
|
1614
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1615
|
+
|
|
1616
|
+
// Create 3
|
|
1617
|
+
for (let i = 0; i < 3; i++) {
|
|
1618
|
+
await mock.handle("POST", "/pets", { body: { name: `V1-${i}` } });
|
|
1619
|
+
}
|
|
1620
|
+
// Delete all
|
|
1621
|
+
for (let i = 1; i <= 3; i++) {
|
|
1622
|
+
await mock.handle("DELETE", `/pets/${i}`);
|
|
1623
|
+
}
|
|
1624
|
+
// Create 3 more — IDs should be 4, 5, 6 (not 1, 2, 3)
|
|
1625
|
+
for (let i = 0; i < 3; i++) {
|
|
1626
|
+
const res = await mock.handle("POST", "/pets", {
|
|
1627
|
+
body: { name: `V2-${i}` },
|
|
1628
|
+
});
|
|
1629
|
+
expect((res.body as Record<string, unknown>).petId).toBe(i + 4);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const list = await mock.handle("GET", "/pets");
|
|
1633
|
+
expect(list.body).toHaveLength(3);
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it("update every field on every planet", async () => {
|
|
1637
|
+
const mock = schmock({ state: {} });
|
|
1638
|
+
mock.pipe(
|
|
1639
|
+
await openapi({
|
|
1640
|
+
spec: scalarGalaxySpec,
|
|
1641
|
+
seed: { planets: solarSystemPlanets },
|
|
1642
|
+
}),
|
|
1643
|
+
);
|
|
1644
|
+
|
|
1645
|
+
// Mass update — change every planet's name
|
|
1646
|
+
for (let i = 1; i <= 8; i++) {
|
|
1647
|
+
await mock.handle("PUT", `/planets/${i}`, {
|
|
1648
|
+
body: { name: `Renamed-${i}` },
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Verify all renames stuck
|
|
1653
|
+
for (let i = 1; i <= 8; i++) {
|
|
1654
|
+
const res = await mock.handle("GET", `/planets/${i}`);
|
|
1655
|
+
expect((res.body as Record<string, unknown>).name).toBe(`Renamed-${i}`);
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
it("interleaved create and delete — Swiss cheese collection", async () => {
|
|
1660
|
+
const mock = schmock({ state: {} });
|
|
1661
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1662
|
+
|
|
1663
|
+
// Create 20 items, delete every 3rd one
|
|
1664
|
+
for (let i = 0; i < 20; i++) {
|
|
1665
|
+
await mock.handle("POST", "/pets", { body: { name: `Pet-${i}` } });
|
|
1666
|
+
if ((i + 1) % 3 === 0) {
|
|
1667
|
+
await mock.handle("DELETE", `/pets/${i + 1}`);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const list = await mock.handle("GET", "/pets");
|
|
1672
|
+
const items = list.body as Record<string, unknown>[];
|
|
1673
|
+
// Deleted items: 3, 6, 9, 12, 15, 18 → 6 deleted, 14 remain
|
|
1674
|
+
expect(items).toHaveLength(14);
|
|
1675
|
+
|
|
1676
|
+
// Verify the deleted ones are really gone
|
|
1677
|
+
for (const deletedId of [3, 6, 9, 12, 15, 18]) {
|
|
1678
|
+
const res = await mock.handle("GET", `/pets/${deletedId}`);
|
|
1679
|
+
expect(res.status).toBe(404);
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
it("rapid overwrite — PUT same item 50 times", async () => {
|
|
1684
|
+
const mock = schmock({ state: {} });
|
|
1685
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1686
|
+
|
|
1687
|
+
await mock.handle("POST", "/pets", { body: { name: "Volatile" } });
|
|
1688
|
+
|
|
1689
|
+
for (let i = 0; i < 50; i++) {
|
|
1690
|
+
await mock.handle("PUT", "/pets/1", {
|
|
1691
|
+
body: { name: `Version-${i}`, counter: i },
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const final = await mock.handle("GET", "/pets/1");
|
|
1696
|
+
const body = final.body as Record<string, unknown>;
|
|
1697
|
+
expect(body.name).toBe("Version-49");
|
|
1698
|
+
expect(body.counter).toBe(49);
|
|
1699
|
+
expect(body.petId).toBe(1); // ID preserved through 50 updates
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
it("delete middle, read neighbors — no index corruption", async () => {
|
|
1703
|
+
const mock = schmock({ state: {} });
|
|
1704
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1705
|
+
|
|
1706
|
+
for (let i = 0; i < 5; i++) {
|
|
1707
|
+
await mock.handle("POST", "/pets", { body: { name: `Pet-${i}` } });
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Delete middle item (ID 3)
|
|
1711
|
+
await mock.handle("DELETE", "/pets/3");
|
|
1712
|
+
|
|
1713
|
+
// Neighbors are still accessible
|
|
1714
|
+
const before = await mock.handle("GET", "/pets/2");
|
|
1715
|
+
expect(before.status).toBe(200);
|
|
1716
|
+
expect((before.body as Record<string, unknown>).name).toBe("Pet-1");
|
|
1717
|
+
|
|
1718
|
+
const after = await mock.handle("GET", "/pets/4");
|
|
1719
|
+
expect(after.status).toBe(200);
|
|
1720
|
+
expect((after.body as Record<string, unknown>).name).toBe("Pet-3");
|
|
1721
|
+
|
|
1722
|
+
// List is still ordered
|
|
1723
|
+
const list = await mock.handle("GET", "/pets");
|
|
1724
|
+
expect(list.body).toHaveLength(4);
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
it("update preserves fields not in the body", async () => {
|
|
1728
|
+
const mock = schmock({ state: {} });
|
|
1729
|
+
mock.pipe(
|
|
1730
|
+
await openapi({
|
|
1731
|
+
spec: scalarGalaxySpec,
|
|
1732
|
+
seed: {
|
|
1733
|
+
planets: [
|
|
1734
|
+
{
|
|
1735
|
+
planetId: 1,
|
|
1736
|
+
name: "Earth",
|
|
1737
|
+
type: "terrestrial",
|
|
1738
|
+
habitabilityIndex: 1.0,
|
|
1739
|
+
physicalProperties: { mass: 1.0, radius: 1.0 },
|
|
1740
|
+
atmosphere: [{ compound: "N2", percentage: 78.1 }],
|
|
1741
|
+
},
|
|
1742
|
+
],
|
|
1743
|
+
},
|
|
1744
|
+
}),
|
|
1745
|
+
);
|
|
1746
|
+
|
|
1747
|
+
// Only update one field
|
|
1748
|
+
await mock.handle("PATCH", "/planets/1", {
|
|
1749
|
+
body: { habitabilityIndex: 0.95 },
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
const res = await mock.handle("GET", "/planets/1");
|
|
1753
|
+
const body = res.body as Record<string, unknown>;
|
|
1754
|
+
// Updated field changed
|
|
1755
|
+
expect(body.habitabilityIndex).toBe(0.95);
|
|
1756
|
+
// Other fields preserved
|
|
1757
|
+
expect(body.name).toBe("Earth");
|
|
1758
|
+
expect(body.type).toBe("terrestrial");
|
|
1759
|
+
expect(body.physicalProperties).toEqual({ mass: 1.0, radius: 1.0 });
|
|
1760
|
+
expect(body.atmosphere).toEqual([{ compound: "N2", percentage: 78.1 }]);
|
|
1761
|
+
});
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1765
|
+
// 14. THE INTEGRATION TESTER — realistic multi-spec E2E flows
|
|
1766
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1767
|
+
describe("stress: realistic E2E flows", () => {
|
|
1768
|
+
it("train travel: book → pay → cancel flow", async () => {
|
|
1769
|
+
const mock = schmock({ state: {} });
|
|
1770
|
+
mock.pipe(await openapi({ spec: trainTravelSpec }));
|
|
1771
|
+
|
|
1772
|
+
// 1. Search stations (static)
|
|
1773
|
+
const stations = await mock.handle("GET", "/stations");
|
|
1774
|
+
expect(stations.status).toBe(200);
|
|
1775
|
+
|
|
1776
|
+
// 2. Search trips (static)
|
|
1777
|
+
const trips = await mock.handle("GET", "/trips");
|
|
1778
|
+
expect(trips.status).toBe(200);
|
|
1779
|
+
|
|
1780
|
+
// 3. Book a trip
|
|
1781
|
+
const booking = await mock.handle("POST", "/bookings", {
|
|
1782
|
+
body: {
|
|
1783
|
+
trip_id: "trip-123",
|
|
1784
|
+
passenger_name: "Jane Doe",
|
|
1785
|
+
has_bicycle: false,
|
|
1786
|
+
has_dog: true,
|
|
1787
|
+
},
|
|
1788
|
+
});
|
|
1789
|
+
expect(booking.status).toBe(201);
|
|
1790
|
+
const bookingBody = booking.body as Record<string, unknown>;
|
|
1791
|
+
const bookingId = bookingBody.bookingId;
|
|
1792
|
+
|
|
1793
|
+
// 4. Read the booking back
|
|
1794
|
+
const readBooking = await mock.handle("GET", `/bookings/${bookingId}`);
|
|
1795
|
+
expect(readBooking.status).toBe(200);
|
|
1796
|
+
expect((readBooking.body as Record<string, unknown>).passenger_name).toBe(
|
|
1797
|
+
"Jane Doe",
|
|
1798
|
+
);
|
|
1799
|
+
|
|
1800
|
+
// 5. Make payment (non-CRUD static endpoint)
|
|
1801
|
+
const payment = await mock.handle(
|
|
1802
|
+
"POST",
|
|
1803
|
+
`/bookings/${bookingId}/payment`,
|
|
1804
|
+
{
|
|
1805
|
+
body: {
|
|
1806
|
+
amount: 49.99,
|
|
1807
|
+
currency: "gbp",
|
|
1808
|
+
source: { object: "card", name: "J. Doe" },
|
|
1809
|
+
},
|
|
1810
|
+
},
|
|
1811
|
+
);
|
|
1812
|
+
expect(payment.status).toBe(200);
|
|
1813
|
+
|
|
1814
|
+
// 6. Cancel booking
|
|
1815
|
+
const cancel = await mock.handle("DELETE", `/bookings/${bookingId}`);
|
|
1816
|
+
expect(cancel.status).toBe(204);
|
|
1817
|
+
|
|
1818
|
+
// 7. Verify cancellation
|
|
1819
|
+
const gone = await mock.handle("GET", `/bookings/${bookingId}`);
|
|
1820
|
+
expect(gone.status).toBe(404);
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
it("galaxy: signup → create planet → upload image → list", async () => {
|
|
1824
|
+
const mock = schmock({ state: {} });
|
|
1825
|
+
mock.pipe(await openapi({ spec: scalarGalaxySpec }));
|
|
1826
|
+
|
|
1827
|
+
// 1. Signup
|
|
1828
|
+
const signup = await mock.handle("POST", "/user/signup", {
|
|
1829
|
+
body: {
|
|
1830
|
+
name: "Explorer",
|
|
1831
|
+
email: "explorer@galaxy.com",
|
|
1832
|
+
password: "stars123",
|
|
1833
|
+
},
|
|
1834
|
+
});
|
|
1835
|
+
expect(signup.status).toBe(200);
|
|
1836
|
+
|
|
1837
|
+
// 2. Get token
|
|
1838
|
+
const token = await mock.handle("POST", "/auth/token", {
|
|
1839
|
+
body: { email: "explorer@galaxy.com", password: "stars123" },
|
|
1840
|
+
});
|
|
1841
|
+
expect(token.status).toBe(200);
|
|
1842
|
+
|
|
1843
|
+
// 3. Create a planet
|
|
1844
|
+
const created = await mock.handle("POST", "/planets", {
|
|
1845
|
+
body: { name: "Explorer-1", type: "super_earth" },
|
|
1846
|
+
});
|
|
1847
|
+
expect(created.status).toBe(201);
|
|
1848
|
+
const planet = created.body as Record<string, unknown>;
|
|
1849
|
+
|
|
1850
|
+
// 4. Upload an image
|
|
1851
|
+
const image = await mock.handle(
|
|
1852
|
+
"POST",
|
|
1853
|
+
`/planets/${planet.planetId}/image`,
|
|
1854
|
+
);
|
|
1855
|
+
expect(image.status).toBe(200);
|
|
1856
|
+
|
|
1857
|
+
// 5. Get user profile
|
|
1858
|
+
const me = await mock.handle("GET", "/me");
|
|
1859
|
+
expect(me.status).toBe(200);
|
|
1860
|
+
|
|
1861
|
+
// 6. List planets
|
|
1862
|
+
const list = await mock.handle("GET", "/planets");
|
|
1863
|
+
expect(list.body).toHaveLength(1);
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
it("galaxy: populate solar system then explore", async () => {
|
|
1867
|
+
const mock = schmock({ state: {} });
|
|
1868
|
+
mock.pipe(await openapi({ spec: scalarGalaxySpec }));
|
|
1869
|
+
|
|
1870
|
+
// Manually add all 8 planets via POST
|
|
1871
|
+
const planetNames = [
|
|
1872
|
+
"Mercury",
|
|
1873
|
+
"Venus",
|
|
1874
|
+
"Earth",
|
|
1875
|
+
"Mars",
|
|
1876
|
+
"Jupiter",
|
|
1877
|
+
"Saturn",
|
|
1878
|
+
"Uranus",
|
|
1879
|
+
"Neptune",
|
|
1880
|
+
];
|
|
1881
|
+
for (const name of planetNames) {
|
|
1882
|
+
const res = await mock.handle("POST", "/planets", {
|
|
1883
|
+
body: { name },
|
|
1884
|
+
});
|
|
1885
|
+
expect(res.status).toBe(201);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Verify count
|
|
1889
|
+
const list = await mock.handle("GET", "/planets");
|
|
1890
|
+
expect(list.body).toHaveLength(8);
|
|
1891
|
+
|
|
1892
|
+
// Read each by ID
|
|
1893
|
+
for (let i = 1; i <= 8; i++) {
|
|
1894
|
+
const res = await mock.handle("GET", `/planets/${i}`);
|
|
1895
|
+
expect(res.status).toBe(200);
|
|
1896
|
+
expect((res.body as Record<string, unknown>).name).toBe(
|
|
1897
|
+
planetNames[i - 1],
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
it("petstore: multiple spec versions coexist", async () => {
|
|
1903
|
+
const mock = schmock({ state: {} });
|
|
1904
|
+
// Load Swagger 2.0 petstore
|
|
1905
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1906
|
+
// Load OpenAPI 3.0 petstore (different fixture)
|
|
1907
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-openapi3.json` }));
|
|
1908
|
+
|
|
1909
|
+
// Both specs define /pets — last one wins for route registration
|
|
1910
|
+
const created = await mock.handle("POST", "/pets", {
|
|
1911
|
+
body: { name: "Multi-version" },
|
|
1912
|
+
});
|
|
1913
|
+
expect(created.status).toBe(201);
|
|
1914
|
+
});
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1918
|
+
// 15. THE EDGE HUNTER — boundary conditions, weird inputs
|
|
1919
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1920
|
+
describe("stress: boundary conditions", () => {
|
|
1921
|
+
it("create with empty object body", async () => {
|
|
1922
|
+
const mock = schmock({ state: {} });
|
|
1923
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1924
|
+
|
|
1925
|
+
const res = await mock.handle("POST", "/pets", { body: {} });
|
|
1926
|
+
expect(res.status).toBe(201);
|
|
1927
|
+
const body = res.body as Record<string, unknown>;
|
|
1928
|
+
expect(body.petId).toBe(1);
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
it("update with body containing nested nulls", async () => {
|
|
1932
|
+
const mock = schmock({ state: {} });
|
|
1933
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
1934
|
+
|
|
1935
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
1936
|
+
const updated = await mock.handle("PUT", "/pets/1", {
|
|
1937
|
+
body: { name: null, extra: undefined },
|
|
1938
|
+
});
|
|
1939
|
+
expect(updated.status).toBe(200);
|
|
1940
|
+
const body = updated.body as Record<string, unknown>;
|
|
1941
|
+
expect(body.name).toBeNull();
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
it("seed with zero items — collection starts empty", async () => {
|
|
1945
|
+
const mock = schmock({ state: {} });
|
|
1946
|
+
mock.pipe(
|
|
1947
|
+
await openapi({
|
|
1948
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
1949
|
+
seed: { pets: [] },
|
|
1950
|
+
}),
|
|
1951
|
+
);
|
|
1952
|
+
|
|
1953
|
+
const list = await mock.handle("GET", "/pets");
|
|
1954
|
+
expect(list.body).toEqual([]);
|
|
1955
|
+
|
|
1956
|
+
// Creating after empty seed still works
|
|
1957
|
+
const created = await mock.handle("POST", "/pets", {
|
|
1958
|
+
body: { name: "First" },
|
|
1959
|
+
});
|
|
1960
|
+
expect((created.body as Record<string, unknown>).petId).toBe(1);
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
it("seed for non-existent resource is silently ignored", async () => {
|
|
1964
|
+
const mock = schmock({ state: {} });
|
|
1965
|
+
mock.pipe(
|
|
1966
|
+
await openapi({
|
|
1967
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
1968
|
+
seed: {
|
|
1969
|
+
pets: [{ petId: 1, name: "Real" }],
|
|
1970
|
+
unicorns: [{ unicornId: 1, name: "Sparkle" }],
|
|
1971
|
+
},
|
|
1972
|
+
}),
|
|
1973
|
+
);
|
|
1974
|
+
|
|
1975
|
+
// pets work fine
|
|
1976
|
+
expect((await mock.handle("GET", "/pets")).body).toHaveLength(1);
|
|
1977
|
+
// unicorns route doesn't exist
|
|
1978
|
+
const res = await mock.handle("GET", "/unicorns");
|
|
1979
|
+
expect(res.status).toBe(404);
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
it("item with deeply nested data survives update cycle", async () => {
|
|
1983
|
+
const mock = schmock({ state: {} });
|
|
1984
|
+
mock.pipe(
|
|
1985
|
+
await openapi({
|
|
1986
|
+
spec: scalarGalaxySpec,
|
|
1987
|
+
seed: {
|
|
1988
|
+
planets: [
|
|
1989
|
+
{
|
|
1990
|
+
planetId: 1,
|
|
1991
|
+
name: "Deep",
|
|
1992
|
+
physicalProperties: {
|
|
1993
|
+
mass: 1.0,
|
|
1994
|
+
temperature: { min: 100, max: 400, average: 250 },
|
|
1995
|
+
},
|
|
1996
|
+
atmosphere: [
|
|
1997
|
+
{ compound: "O2", percentage: 21 },
|
|
1998
|
+
{ compound: "N2", percentage: 78 },
|
|
1999
|
+
{ compound: "Ar", percentage: 0.9 },
|
|
2000
|
+
],
|
|
2001
|
+
},
|
|
2002
|
+
],
|
|
2003
|
+
},
|
|
2004
|
+
}),
|
|
2005
|
+
);
|
|
2006
|
+
|
|
2007
|
+
// Update only top-level field
|
|
2008
|
+
await mock.handle("PATCH", "/planets/1", { body: { name: "Deeper" } });
|
|
2009
|
+
|
|
2010
|
+
const res = await mock.handle("GET", "/planets/1");
|
|
2011
|
+
const body = res.body as Record<string, unknown>;
|
|
2012
|
+
expect(body.name).toBe("Deeper");
|
|
2013
|
+
const props = body.physicalProperties as Record<string, unknown>;
|
|
2014
|
+
const temp = props.temperature as Record<string, unknown>;
|
|
2015
|
+
expect(temp.min).toBe(100);
|
|
2016
|
+
expect(temp.max).toBe(400);
|
|
2017
|
+
const atmo = body.atmosphere as Array<Record<string, unknown>>;
|
|
2018
|
+
expect(atmo).toHaveLength(3);
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
it("large ID values work correctly", async () => {
|
|
2022
|
+
const mock = schmock({ state: {} });
|
|
2023
|
+
mock.pipe(
|
|
2024
|
+
await openapi({
|
|
2025
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
2026
|
+
seed: { pets: [{ petId: 999999, name: "Big" }] },
|
|
2027
|
+
}),
|
|
2028
|
+
);
|
|
2029
|
+
|
|
2030
|
+
const read = await mock.handle("GET", "/pets/999999");
|
|
2031
|
+
expect(read.status).toBe(200);
|
|
2032
|
+
|
|
2033
|
+
const created = await mock.handle("POST", "/pets", {
|
|
2034
|
+
body: { name: "After Big" },
|
|
2035
|
+
});
|
|
2036
|
+
expect((created.body as Record<string, unknown>).petId).toBe(1000000);
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
it("UUID-style string IDs", async () => {
|
|
2040
|
+
const mock = schmock({ state: {} });
|
|
2041
|
+
mock.pipe(
|
|
2042
|
+
await openapi({
|
|
2043
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
2044
|
+
seed: {
|
|
2045
|
+
pets: [
|
|
2046
|
+
{ petId: "550e8400-e29b-41d4-a716-446655440000", name: "UUID-Pet" },
|
|
2047
|
+
],
|
|
2048
|
+
},
|
|
2049
|
+
}),
|
|
2050
|
+
);
|
|
2051
|
+
|
|
2052
|
+
const res = await mock.handle(
|
|
2053
|
+
"GET",
|
|
2054
|
+
"/pets/550e8400-e29b-41d4-a716-446655440000",
|
|
2055
|
+
);
|
|
2056
|
+
expect(res.status).toBe(200);
|
|
2057
|
+
expect((res.body as Record<string, unknown>).name).toBe("UUID-Pet");
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
it("concurrent-like rapid operations don't corrupt state", async () => {
|
|
2061
|
+
const mock = schmock({ state: {} });
|
|
2062
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
2063
|
+
|
|
2064
|
+
// Fire off many operations in parallel
|
|
2065
|
+
const ops: Promise<Schmock.ResponseResult>[] = [];
|
|
2066
|
+
for (let i = 0; i < 20; i++) {
|
|
2067
|
+
ops.push(
|
|
2068
|
+
mock.handle("POST", "/pets", { body: { name: `Parallel-${i}` } }),
|
|
2069
|
+
);
|
|
2070
|
+
}
|
|
2071
|
+
const results = await Promise.all(ops);
|
|
2072
|
+
|
|
2073
|
+
// All should succeed
|
|
2074
|
+
for (const res of results) {
|
|
2075
|
+
expect(res.status).toBe(201);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// All should have unique IDs
|
|
2079
|
+
const ids = results.map((r) => (r.body as Record<string, unknown>).petId);
|
|
2080
|
+
const uniqueIds = new Set(ids);
|
|
2081
|
+
expect(uniqueIds.size).toBe(20);
|
|
2082
|
+
|
|
2083
|
+
// List should show all 20
|
|
2084
|
+
const list = await mock.handle("GET", "/pets");
|
|
2085
|
+
expect(list.body).toHaveLength(20);
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
it("handles path with trailing slash gracefully", async () => {
|
|
2089
|
+
const mock = schmock({ state: {} });
|
|
2090
|
+
mock.pipe(await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }));
|
|
2091
|
+
|
|
2092
|
+
await mock.handle("POST", "/pets", { body: { name: "Buddy" } });
|
|
2093
|
+
|
|
2094
|
+
// Trailing slash should be handled by schmock's route matching
|
|
2095
|
+
const res = await mock.handle("GET", "/pets/1");
|
|
2096
|
+
expect(res.status).toBe(200);
|
|
2097
|
+
});
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2101
|
+
// 16. THE SPEC EXPLORER — inline specs with weird shapes
|
|
2102
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2103
|
+
describe("stress: weird inline specs", () => {
|
|
2104
|
+
it("spec with only DELETE endpoints — still CRUD if has collection GET", async () => {
|
|
2105
|
+
const mock = schmock({ state: {} });
|
|
2106
|
+
mock.pipe(
|
|
2107
|
+
await openapi({
|
|
2108
|
+
spec: {
|
|
2109
|
+
openapi: "3.0.3",
|
|
2110
|
+
info: { title: "DeleteHeavy", version: "1.0.0" },
|
|
2111
|
+
paths: {
|
|
2112
|
+
"/items": {
|
|
2113
|
+
get: {
|
|
2114
|
+
responses: {
|
|
2115
|
+
"200": {
|
|
2116
|
+
description: "List",
|
|
2117
|
+
content: {
|
|
2118
|
+
"application/json": {
|
|
2119
|
+
schema: {
|
|
2120
|
+
type: "array",
|
|
2121
|
+
items: { type: "object" },
|
|
2122
|
+
},
|
|
2123
|
+
},
|
|
2124
|
+
},
|
|
2125
|
+
},
|
|
2126
|
+
},
|
|
2127
|
+
},
|
|
2128
|
+
post: {
|
|
2129
|
+
responses: { "201": { description: "Created" } },
|
|
2130
|
+
},
|
|
2131
|
+
},
|
|
2132
|
+
"/items/{itemId}": {
|
|
2133
|
+
delete: {
|
|
2134
|
+
parameters: [{ name: "itemId", in: "path", required: true }],
|
|
2135
|
+
responses: { "204": { description: "Deleted" } },
|
|
2136
|
+
},
|
|
2137
|
+
},
|
|
2138
|
+
},
|
|
2139
|
+
},
|
|
2140
|
+
}),
|
|
2141
|
+
);
|
|
2142
|
+
|
|
2143
|
+
await mock.handle("POST", "/items", { body: { x: 1 } });
|
|
2144
|
+
await mock.handle("POST", "/items", { body: { x: 2 } });
|
|
2145
|
+
expect((await mock.handle("GET", "/items")).body).toHaveLength(2);
|
|
2146
|
+
|
|
2147
|
+
await mock.handle("DELETE", "/items/1");
|
|
2148
|
+
expect((await mock.handle("GET", "/items")).body).toHaveLength(1);
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
it("spec with 10 unrelated static endpoints", async () => {
|
|
2152
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
2153
|
+
for (let i = 0; i < 10; i++) {
|
|
2154
|
+
paths[`/endpoint-${i}`] = {
|
|
2155
|
+
get: {
|
|
2156
|
+
responses: {
|
|
2157
|
+
"200": {
|
|
2158
|
+
description: `Response ${i}`,
|
|
2159
|
+
content: {
|
|
2160
|
+
"application/json": {
|
|
2161
|
+
schema: {
|
|
2162
|
+
type: "object",
|
|
2163
|
+
properties: { index: { type: "integer", default: i } },
|
|
2164
|
+
},
|
|
2165
|
+
},
|
|
2166
|
+
},
|
|
2167
|
+
},
|
|
2168
|
+
},
|
|
2169
|
+
},
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const mock = schmock({ state: {} });
|
|
2174
|
+
mock.pipe(
|
|
2175
|
+
await openapi({
|
|
2176
|
+
spec: {
|
|
2177
|
+
openapi: "3.0.3",
|
|
2178
|
+
info: { title: "Many", version: "1.0.0" },
|
|
2179
|
+
paths,
|
|
2180
|
+
},
|
|
2181
|
+
}),
|
|
2182
|
+
);
|
|
2183
|
+
|
|
2184
|
+
// All 10 endpoints respond
|
|
2185
|
+
for (let i = 0; i < 10; i++) {
|
|
2186
|
+
const res = await mock.handle("GET", `/endpoint-${i}`);
|
|
2187
|
+
expect(res.status).toBe(200);
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
it("spec with multiple CRUD resources", async () => {
|
|
2192
|
+
const mock = schmock({ state: {} });
|
|
2193
|
+
mock.pipe(
|
|
2194
|
+
await openapi({
|
|
2195
|
+
spec: {
|
|
2196
|
+
openapi: "3.0.3",
|
|
2197
|
+
info: { title: "Multi", version: "1.0.0" },
|
|
2198
|
+
paths: {
|
|
2199
|
+
"/dogs": {
|
|
2200
|
+
get: { responses: { "200": { description: "List" } } },
|
|
2201
|
+
post: { responses: { "201": { description: "Created" } } },
|
|
2202
|
+
},
|
|
2203
|
+
"/dogs/{dogId}": {
|
|
2204
|
+
get: {
|
|
2205
|
+
parameters: [{ name: "dogId", in: "path", required: true }],
|
|
2206
|
+
responses: { "200": { description: "Dog" } },
|
|
2207
|
+
},
|
|
2208
|
+
delete: {
|
|
2209
|
+
parameters: [{ name: "dogId", in: "path", required: true }],
|
|
2210
|
+
responses: { "204": { description: "Deleted" } },
|
|
2211
|
+
},
|
|
2212
|
+
},
|
|
2213
|
+
"/cats": {
|
|
2214
|
+
get: { responses: { "200": { description: "List" } } },
|
|
2215
|
+
post: { responses: { "201": { description: "Created" } } },
|
|
2216
|
+
},
|
|
2217
|
+
"/cats/{catId}": {
|
|
2218
|
+
get: {
|
|
2219
|
+
parameters: [{ name: "catId", in: "path", required: true }],
|
|
2220
|
+
responses: { "200": { description: "Cat" } },
|
|
2221
|
+
},
|
|
2222
|
+
},
|
|
2223
|
+
},
|
|
2224
|
+
},
|
|
2225
|
+
}),
|
|
2226
|
+
);
|
|
2227
|
+
|
|
2228
|
+
// Dogs and cats are independent
|
|
2229
|
+
await mock.handle("POST", "/dogs", { body: { name: "Rex" } });
|
|
2230
|
+
await mock.handle("POST", "/dogs", { body: { name: "Spot" } });
|
|
2231
|
+
await mock.handle("POST", "/cats", { body: { name: "Whiskers" } });
|
|
2232
|
+
|
|
2233
|
+
expect((await mock.handle("GET", "/dogs")).body).toHaveLength(2);
|
|
2234
|
+
expect((await mock.handle("GET", "/cats")).body).toHaveLength(1);
|
|
2235
|
+
|
|
2236
|
+
// Delete a dog doesn't affect cats
|
|
2237
|
+
await mock.handle("DELETE", "/dogs/1");
|
|
2238
|
+
expect((await mock.handle("GET", "/dogs")).body).toHaveLength(1);
|
|
2239
|
+
expect((await mock.handle("GET", "/cats")).body).toHaveLength(1);
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
it("multiple non-CRUD static endpoints with different methods", async () => {
|
|
2243
|
+
const mock = schmock({ state: {} });
|
|
2244
|
+
mock.pipe(
|
|
2245
|
+
await openapi({
|
|
2246
|
+
spec: {
|
|
2247
|
+
openapi: "3.0.3",
|
|
2248
|
+
info: { title: "Methods", version: "1.0.0" },
|
|
2249
|
+
paths: {
|
|
2250
|
+
"/health": {
|
|
2251
|
+
get: {
|
|
2252
|
+
responses: {
|
|
2253
|
+
"200": {
|
|
2254
|
+
description: "OK",
|
|
2255
|
+
content: {
|
|
2256
|
+
"application/json": {
|
|
2257
|
+
schema: {
|
|
2258
|
+
type: "object",
|
|
2259
|
+
properties: { up: { type: "boolean" } },
|
|
2260
|
+
},
|
|
2261
|
+
},
|
|
2262
|
+
},
|
|
2263
|
+
},
|
|
2264
|
+
},
|
|
2265
|
+
},
|
|
2266
|
+
},
|
|
2267
|
+
"/webhook": {
|
|
2268
|
+
post: {
|
|
2269
|
+
responses: { "200": { description: "Received" } },
|
|
2270
|
+
},
|
|
2271
|
+
},
|
|
2272
|
+
"/config": {
|
|
2273
|
+
put: {
|
|
2274
|
+
responses: {
|
|
2275
|
+
"200": {
|
|
2276
|
+
description: "Updated",
|
|
2277
|
+
content: {
|
|
2278
|
+
"application/json": {
|
|
2279
|
+
schema: {
|
|
2280
|
+
type: "object",
|
|
2281
|
+
properties: { ok: { type: "boolean" } },
|
|
2282
|
+
},
|
|
2283
|
+
},
|
|
2284
|
+
},
|
|
2285
|
+
},
|
|
2286
|
+
},
|
|
2287
|
+
},
|
|
2288
|
+
},
|
|
2289
|
+
"/cache": {
|
|
2290
|
+
delete: {
|
|
2291
|
+
responses: { "204": { description: "Cleared" } },
|
|
2292
|
+
},
|
|
2293
|
+
},
|
|
2294
|
+
},
|
|
2295
|
+
},
|
|
2296
|
+
}),
|
|
2297
|
+
);
|
|
2298
|
+
|
|
2299
|
+
expect((await mock.handle("GET", "/health")).status).toBe(200);
|
|
2300
|
+
expect((await mock.handle("POST", "/webhook")).status).toBe(200);
|
|
2301
|
+
expect((await mock.handle("PUT", "/config")).status).toBe(200);
|
|
2302
|
+
expect((await mock.handle("DELETE", "/cache")).status).toBe(200);
|
|
2303
|
+
});
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2307
|
+
// 17. STRIPE — 5.8MB / 415 paths / the ultimate parser stress test
|
|
2308
|
+
//
|
|
2309
|
+
// The Stripe OpenAPI spec is one of the largest real-world specs:
|
|
2310
|
+
// - 161K lines, 5.8MB YAML
|
|
2311
|
+
// - 415 path operations (137 plain + 278 parameterized)
|
|
2312
|
+
// - 113 potential CRUD resource pairs
|
|
2313
|
+
// - Heavy anyOf usage, x-stripe* extensions, form-urlencoded bodies
|
|
2314
|
+
// - Product item path uses {id} not {product}
|
|
2315
|
+
// - Customer GET returns anyOf: [customer, deleted_customer]
|
|
2316
|
+
//
|
|
2317
|
+
// To avoid OOM from repeated parsing, we parse ONCE and share state.
|
|
2318
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2319
|
+
|
|
2320
|
+
const stripeSpec = resolve(fixturesDir, "stripe-spec3.yaml");
|
|
2321
|
+
const stripeFixtures: Record<string, unknown> = JSON.parse(
|
|
2322
|
+
readFileSync(resolve(fixturesDir, "stripe-fixtures3.json"), "utf8"),
|
|
2323
|
+
);
|
|
2324
|
+
const stripeResources = (
|
|
2325
|
+
stripeFixtures as { resources: Record<string, Record<string, unknown>> }
|
|
2326
|
+
).resources;
|
|
2327
|
+
|
|
2328
|
+
// Module-level cache — parse the 5.8MB spec exactly once across all test blocks
|
|
2329
|
+
let cachedStripeSpec: ParsedSpec | undefined;
|
|
2330
|
+
async function getStripeSpec(): Promise<ParsedSpec> {
|
|
2331
|
+
if (!cachedStripeSpec) {
|
|
2332
|
+
cachedStripeSpec = await parseSpec(stripeSpec);
|
|
2333
|
+
}
|
|
2334
|
+
return cachedStripeSpec;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// Shared plugin — also created once to avoid redundant parsing
|
|
2338
|
+
let cachedStripePlugin: Schmock.Plugin | undefined;
|
|
2339
|
+
async function getStripePlugin(): Promise<Schmock.Plugin> {
|
|
2340
|
+
if (!cachedStripePlugin) {
|
|
2341
|
+
cachedStripePlugin = await openapi({ spec: stripeSpec });
|
|
2342
|
+
}
|
|
2343
|
+
return cachedStripePlugin;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
describe("stress: stripe spec — parser (5.8MB)", () => {
|
|
2347
|
+
let spec: ParsedSpec;
|
|
2348
|
+
|
|
2349
|
+
beforeAll(async () => {
|
|
2350
|
+
spec = await getStripeSpec();
|
|
2351
|
+
}, 120_000);
|
|
2352
|
+
|
|
2353
|
+
it("parses the massive OpenAPI 3.0 Stripe spec without crashing", () => {
|
|
2354
|
+
expect(spec.title).toBe("Stripe API");
|
|
2355
|
+
expect(spec.version).toBe("2026-01-28.clover");
|
|
2356
|
+
}, 120_000);
|
|
2357
|
+
|
|
2358
|
+
it("extracts basePath from server URL", () => {
|
|
2359
|
+
// servers[0] = "https://api.stripe.com/" → pathname "/" → normalized to ""
|
|
2360
|
+
expect(spec.basePath).toBe("");
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
it("extracts 400+ path operations", () => {
|
|
2364
|
+
expect(spec.paths.length).toBeGreaterThan(400);
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
it("extracts customer CRUD paths", () => {
|
|
2368
|
+
const sigs = spec.paths.map((p) => `${p.method} ${p.path}`);
|
|
2369
|
+
expect(sigs).toContain("GET /v1/customers");
|
|
2370
|
+
expect(sigs).toContain("POST /v1/customers");
|
|
2371
|
+
expect(sigs).toContain("GET /v1/customers/:customer");
|
|
2372
|
+
expect(sigs).toContain("POST /v1/customers/:customer");
|
|
2373
|
+
expect(sigs).toContain("DELETE /v1/customers/:customer");
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
it("extracts product paths (item path uses {id} not {product})", () => {
|
|
2377
|
+
const sigs = spec.paths.map((p) => `${p.method} ${p.path}`);
|
|
2378
|
+
expect(sigs).toContain("GET /v1/products");
|
|
2379
|
+
expect(sigs).toContain("POST /v1/products");
|
|
2380
|
+
// Stripe uses /v1/products/{id} — NOT /v1/products/{product}
|
|
2381
|
+
expect(sigs).toContain("GET /v1/products/:id");
|
|
2382
|
+
expect(sigs).toContain("POST /v1/products/:id");
|
|
2383
|
+
expect(sigs).toContain("DELETE /v1/products/:id");
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
it("extracts coupon CRUD paths", () => {
|
|
2387
|
+
const sigs = spec.paths.map((p) => `${p.method} ${p.path}`);
|
|
2388
|
+
expect(sigs).toContain("GET /v1/coupons");
|
|
2389
|
+
expect(sigs).toContain("POST /v1/coupons");
|
|
2390
|
+
expect(sigs).toContain("GET /v1/coupons/:coupon");
|
|
2391
|
+
expect(sigs).toContain("POST /v1/coupons/:coupon");
|
|
2392
|
+
expect(sigs).toContain("DELETE /v1/coupons/:coupon");
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
it("converts {param} path templates to :param", () => {
|
|
2396
|
+
const paramPaths = spec.paths.filter((p) => p.path.includes(":"));
|
|
2397
|
+
expect(paramPaths.length).toBeGreaterThan(200);
|
|
2398
|
+
// No unreplaced {param} templates
|
|
2399
|
+
const unreplaced = spec.paths.filter((p) => p.path.includes("{"));
|
|
2400
|
+
expect(unreplaced).toHaveLength(0);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
it("strips x-stripe* extensions from all schemas", () => {
|
|
2404
|
+
let extensionFound = false;
|
|
2405
|
+
for (const p of spec.paths) {
|
|
2406
|
+
if (p.requestBody) {
|
|
2407
|
+
const str = JSON.stringify(p.requestBody);
|
|
2408
|
+
if (str.includes('"x-')) {
|
|
2409
|
+
extensionFound = true;
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
for (const [, resp] of p.responses) {
|
|
2414
|
+
if (resp.schema) {
|
|
2415
|
+
const str = JSON.stringify(resp.schema);
|
|
2416
|
+
if (str.includes('"x-')) {
|
|
2417
|
+
extensionFound = true;
|
|
2418
|
+
break;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
if (extensionFound) break;
|
|
2423
|
+
}
|
|
2424
|
+
expect(extensionFound).toBe(false);
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
it("no unresolved $ref in any path", () => {
|
|
2428
|
+
for (const p of spec.paths) {
|
|
2429
|
+
if (p.requestBody) {
|
|
2430
|
+
expect(JSON.stringify(p.requestBody)).not.toContain('"$ref"');
|
|
2431
|
+
}
|
|
2432
|
+
for (const [, resp] of p.responses) {
|
|
2433
|
+
if (resp.schema) {
|
|
2434
|
+
expect(JSON.stringify(resp.schema)).not.toContain('"$ref"');
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
|
|
2440
|
+
it("handles anyOf response schemas (customer → anyOf [customer, deleted_customer])", () => {
|
|
2441
|
+
// Stripe's GET /v1/customers/{customer} returns anyOf: [customer, deleted_customer]
|
|
2442
|
+
const getCustomer = spec.paths.find(
|
|
2443
|
+
(p) => p.method === "GET" && p.path === "/v1/customers/:customer",
|
|
2444
|
+
);
|
|
2445
|
+
expect(getCustomer).toBeDefined();
|
|
2446
|
+
const schema = getCustomer?.responses.get(200)?.schema;
|
|
2447
|
+
expect(schema).toBeDefined();
|
|
2448
|
+
// Schema is anyOf at the top level — no direct properties
|
|
2449
|
+
expect(schema?.anyOf).toBeDefined();
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
it("handles application/x-www-form-urlencoded request bodies", () => {
|
|
2453
|
+
// Stripe uses form-encoded bodies, not JSON — our fallback should find them
|
|
2454
|
+
const createCustomer = spec.paths.find(
|
|
2455
|
+
(p) => p.method === "POST" && p.path === "/v1/customers",
|
|
2456
|
+
);
|
|
2457
|
+
expect(createCustomer).toBeDefined();
|
|
2458
|
+
// Our findJsonContent falls back to any content type
|
|
2459
|
+
expect(createCustomer?.requestBody).toBeDefined();
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
it("extracts query parameters (limit)", () => {
|
|
2463
|
+
const listCustomers = spec.paths.find(
|
|
2464
|
+
(p) => p.method === "GET" && p.path === "/v1/customers",
|
|
2465
|
+
);
|
|
2466
|
+
const paramNames = listCustomers?.parameters.map((p) => p.name) ?? [];
|
|
2467
|
+
expect(paramNames).toContain("limit");
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
it("extracts path parameters for item endpoints", () => {
|
|
2471
|
+
const getCustomer = spec.paths.find(
|
|
2472
|
+
(p) => p.method === "GET" && p.path === "/v1/customers/:customer",
|
|
2473
|
+
);
|
|
2474
|
+
expect(getCustomer?.parameters).toContainEqual(
|
|
2475
|
+
expect.objectContaining({ name: "customer", in: "path" }),
|
|
2476
|
+
);
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2479
|
+
it("extracts list response with data wrapper schema", () => {
|
|
2480
|
+
// Stripe list endpoints return { data: [...], has_more, url }
|
|
2481
|
+
const listCustomers = spec.paths.find(
|
|
2482
|
+
(p) => p.method === "GET" && p.path === "/v1/customers",
|
|
2483
|
+
);
|
|
2484
|
+
const schema = listCustomers?.responses.get(200)?.schema;
|
|
2485
|
+
expect(schema).toBeDefined();
|
|
2486
|
+
// Should have properties or be an allOf/anyOf composition
|
|
2487
|
+
expect(schema?.properties ?? schema?.allOf ?? schema?.anyOf).toBeDefined();
|
|
2488
|
+
});
|
|
2489
|
+
|
|
2490
|
+
it("extracts operationIds", () => {
|
|
2491
|
+
const ops = spec.paths.filter((p) => p.operationId);
|
|
2492
|
+
expect(ops.length).toBeGreaterThan(300);
|
|
2493
|
+
const listCustomers = spec.paths.find(
|
|
2494
|
+
(p) => p.method === "GET" && p.path === "/v1/customers",
|
|
2495
|
+
);
|
|
2496
|
+
expect(listCustomers?.operationId).toBe("GetCustomers");
|
|
2497
|
+
});
|
|
2498
|
+
|
|
2499
|
+
it("handles deeply nested paths (customers → balance_transactions)", () => {
|
|
2500
|
+
const sigs = spec.paths.map((p) => `${p.method} ${p.path}`);
|
|
2501
|
+
expect(sigs).toContain("GET /v1/customers/:customer/balance_transactions");
|
|
2502
|
+
expect(sigs).toContain(
|
|
2503
|
+
"GET /v1/customers/:customer/balance_transactions/:transaction",
|
|
2504
|
+
);
|
|
2505
|
+
});
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
describe("stress: stripe spec — CRUD detection", () => {
|
|
2509
|
+
let spec: ParsedSpec;
|
|
2510
|
+
|
|
2511
|
+
beforeAll(async () => {
|
|
2512
|
+
spec = await getStripeSpec();
|
|
2513
|
+
}, 120_000);
|
|
2514
|
+
|
|
2515
|
+
it("detects CRUD resources from 415 paths", () => {
|
|
2516
|
+
const result = detectCrudResources(spec.paths);
|
|
2517
|
+
expect(result.resources.length).toBeGreaterThan(0);
|
|
2518
|
+
expect(result.nonCrudPaths.length).toBeGreaterThan(0);
|
|
2519
|
+
}, 120_000);
|
|
2520
|
+
|
|
2521
|
+
it("detects customers as a CRUD resource", () => {
|
|
2522
|
+
const result = detectCrudResources(spec.paths);
|
|
2523
|
+
const customers = result.resources.find((r) => r.name === "customers");
|
|
2524
|
+
expect(customers).toBeDefined();
|
|
2525
|
+
expect(customers?.basePath).toBe("/v1/customers");
|
|
2526
|
+
expect(customers?.itemPath).toBe("/v1/customers/:customer");
|
|
2527
|
+
expect(customers?.idParam).toBe("customer");
|
|
2528
|
+
expect(customers?.operations).toContain("list");
|
|
2529
|
+
expect(customers?.operations).toContain("create");
|
|
2530
|
+
expect(customers?.operations).toContain("read");
|
|
2531
|
+
expect(customers?.operations).toContain("delete");
|
|
2532
|
+
});
|
|
2533
|
+
|
|
2534
|
+
it("detects /v1/products as CRUD with idParam=id", () => {
|
|
2535
|
+
const result = detectCrudResources(spec.paths);
|
|
2536
|
+
// Use basePath to distinguish from /v1/climate/products (also named "products")
|
|
2537
|
+
const products = result.resources.find(
|
|
2538
|
+
(r) => r.basePath === "/v1/products",
|
|
2539
|
+
);
|
|
2540
|
+
expect(products).toBeDefined();
|
|
2541
|
+
// Stripe uses /v1/products/{id} — idParam is "id" (not "product")
|
|
2542
|
+
expect(products?.idParam).toBe("id");
|
|
2543
|
+
expect(products?.itemPath).toBe("/v1/products/:id");
|
|
2544
|
+
expect(products?.operations).toContain("list");
|
|
2545
|
+
expect(products?.operations).toContain("create");
|
|
2546
|
+
expect(products?.operations).toContain("read");
|
|
2547
|
+
expect(products?.operations).toContain("delete");
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
it("detects coupons as a CRUD resource", () => {
|
|
2551
|
+
const result = detectCrudResources(spec.paths);
|
|
2552
|
+
const coupons = result.resources.find((r) => r.name === "coupons");
|
|
2553
|
+
expect(coupons).toBeDefined();
|
|
2554
|
+
expect(coupons?.idParam).toBe("coupon");
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
it("classifies /v1/balance as non-CRUD (singleton)", () => {
|
|
2558
|
+
const result = detectCrudResources(spec.paths);
|
|
2559
|
+
const balanceResource = result.resources.find(
|
|
2560
|
+
(r) => r.basePath === "/v1/balance",
|
|
2561
|
+
);
|
|
2562
|
+
expect(balanceResource).toBeUndefined();
|
|
2563
|
+
const balanceNonCrud = result.nonCrudPaths.find(
|
|
2564
|
+
(p) => p.path === "/v1/balance",
|
|
2565
|
+
);
|
|
2566
|
+
expect(balanceNonCrud).toBeDefined();
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
it("classifies search endpoints as non-CRUD", () => {
|
|
2570
|
+
const result = detectCrudResources(spec.paths);
|
|
2571
|
+
const searchPaths = result.nonCrudPaths.filter((p) =>
|
|
2572
|
+
p.path.endsWith("/search"),
|
|
2573
|
+
);
|
|
2574
|
+
expect(searchPaths.length).toBeGreaterThan(0);
|
|
2575
|
+
});
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
describe("stress: stripe spec — CRUD lifecycle with fixtures", () => {
|
|
2579
|
+
// Share ONE plugin instance across all lifecycle tests to avoid OOM
|
|
2580
|
+
let plugin: Schmock.Plugin;
|
|
2581
|
+
|
|
2582
|
+
beforeAll(async () => {
|
|
2583
|
+
plugin = await getStripePlugin();
|
|
2584
|
+
}, 120_000);
|
|
2585
|
+
|
|
2586
|
+
it("customer CRUD lifecycle with real fixture data", async () => {
|
|
2587
|
+
const mock = schmock({ state: {} });
|
|
2588
|
+
mock.pipe(plugin);
|
|
2589
|
+
|
|
2590
|
+
// Create from fixture data
|
|
2591
|
+
const custFixture = stripeResources.customer;
|
|
2592
|
+
const created = await mock.handle("POST", "/v1/customers", {
|
|
2593
|
+
body: {
|
|
2594
|
+
email: custFixture.email,
|
|
2595
|
+
name: custFixture.name,
|
|
2596
|
+
description: custFixture.description,
|
|
2597
|
+
currency: custFixture.currency,
|
|
2598
|
+
},
|
|
2599
|
+
});
|
|
2600
|
+
expect(created.status).toBe(201);
|
|
2601
|
+
const cust = created.body as Record<string, unknown>;
|
|
2602
|
+
expect(cust.email).toBe(custFixture.email);
|
|
2603
|
+
expect(cust.customer).toBe(1);
|
|
2604
|
+
|
|
2605
|
+
// Read
|
|
2606
|
+
const read = await mock.handle("GET", "/v1/customers/1");
|
|
2607
|
+
expect(read.status).toBe(200);
|
|
2608
|
+
expect((read.body as Record<string, unknown>).email).toBe(
|
|
2609
|
+
custFixture.email,
|
|
2610
|
+
);
|
|
2611
|
+
|
|
2612
|
+
// List
|
|
2613
|
+
const list = await mock.handle("GET", "/v1/customers");
|
|
2614
|
+
expect(list.status).toBe(200);
|
|
2615
|
+
expect(list.body).toHaveLength(1);
|
|
2616
|
+
|
|
2617
|
+
// Delete
|
|
2618
|
+
const deleted = await mock.handle("DELETE", "/v1/customers/1");
|
|
2619
|
+
expect(deleted.status).toBe(204);
|
|
2620
|
+
|
|
2621
|
+
// Verify deleted
|
|
2622
|
+
const gone = await mock.handle("GET", "/v1/customers/1");
|
|
2623
|
+
expect(gone.status).toBe(404);
|
|
2624
|
+
}, 120_000);
|
|
2625
|
+
|
|
2626
|
+
it("product CRUD uses idParam=id (from Stripe's {id} path template)", async () => {
|
|
2627
|
+
const mock = schmock({ state: {} });
|
|
2628
|
+
mock.pipe(plugin);
|
|
2629
|
+
|
|
2630
|
+
// Create — product idParam is "id", auto-incremented
|
|
2631
|
+
const created = await mock.handle("POST", "/v1/products", {
|
|
2632
|
+
body: { name: "Premium Plan", active: true },
|
|
2633
|
+
});
|
|
2634
|
+
expect(created.status).toBe(201);
|
|
2635
|
+
const prod = created.body as Record<string, unknown>;
|
|
2636
|
+
expect(prod.id).toBe(1);
|
|
2637
|
+
expect(prod.name).toBe("Premium Plan");
|
|
2638
|
+
|
|
2639
|
+
// Read by "id"
|
|
2640
|
+
const read = await mock.handle("GET", "/v1/products/1");
|
|
2641
|
+
expect(read.status).toBe(200);
|
|
2642
|
+
expect((read.body as Record<string, unknown>).name).toBe("Premium Plan");
|
|
2643
|
+
|
|
2644
|
+
// List
|
|
2645
|
+
const list = await mock.handle("GET", "/v1/products");
|
|
2646
|
+
expect(list.body).toHaveLength(1);
|
|
2647
|
+
|
|
2648
|
+
// Delete
|
|
2649
|
+
const del = await mock.handle("DELETE", "/v1/products/1");
|
|
2650
|
+
expect(del.status).toBe(204);
|
|
2651
|
+
}, 120_000);
|
|
2652
|
+
|
|
2653
|
+
it("coupon CRUD with Stripe fixture data", async () => {
|
|
2654
|
+
const mock = schmock({ state: {} });
|
|
2655
|
+
mock.pipe(plugin);
|
|
2656
|
+
|
|
2657
|
+
const couponFixture = stripeResources.coupon;
|
|
2658
|
+
// Create with fixture data
|
|
2659
|
+
const created = await mock.handle("POST", "/v1/coupons", {
|
|
2660
|
+
body: {
|
|
2661
|
+
name: couponFixture.name,
|
|
2662
|
+
percent_off: couponFixture.percent_off,
|
|
2663
|
+
duration: couponFixture.duration,
|
|
2664
|
+
},
|
|
2665
|
+
});
|
|
2666
|
+
expect(created.status).toBe(201);
|
|
2667
|
+
const coupon = created.body as Record<string, unknown>;
|
|
2668
|
+
expect(coupon.coupon).toBe(1);
|
|
2669
|
+
expect(coupon.duration).toBe(couponFixture.duration);
|
|
2670
|
+
|
|
2671
|
+
// Delete
|
|
2672
|
+
const deleted = await mock.handle("DELETE", "/v1/coupons/1");
|
|
2673
|
+
expect(deleted.status).toBe(204);
|
|
2674
|
+
|
|
2675
|
+
// Gone
|
|
2676
|
+
const gone = await mock.handle("GET", "/v1/coupons/1");
|
|
2677
|
+
expect(gone.status).toBe(404);
|
|
2678
|
+
}, 120_000);
|
|
2679
|
+
|
|
2680
|
+
it("multi-resource: customers + products + coupons on same instance", async () => {
|
|
2681
|
+
const mock = schmock({ state: {} });
|
|
2682
|
+
mock.pipe(plugin);
|
|
2683
|
+
|
|
2684
|
+
// Create across 3 resources
|
|
2685
|
+
await mock.handle("POST", "/v1/customers", {
|
|
2686
|
+
body: { email: "alice@stripe.com" },
|
|
2687
|
+
});
|
|
2688
|
+
await mock.handle("POST", "/v1/customers", {
|
|
2689
|
+
body: { email: "bob@stripe.com" },
|
|
2690
|
+
});
|
|
2691
|
+
await mock.handle("POST", "/v1/products", {
|
|
2692
|
+
body: { name: "Basic" },
|
|
2693
|
+
});
|
|
2694
|
+
await mock.handle("POST", "/v1/coupons", {
|
|
2695
|
+
body: { percent_off: 25 },
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
// Each resource is independent
|
|
2699
|
+
expect((await mock.handle("GET", "/v1/customers")).body).toHaveLength(2);
|
|
2700
|
+
expect((await mock.handle("GET", "/v1/products")).body).toHaveLength(1);
|
|
2701
|
+
expect((await mock.handle("GET", "/v1/coupons")).body).toHaveLength(1);
|
|
2702
|
+
|
|
2703
|
+
// Delete a customer doesn't affect products
|
|
2704
|
+
await mock.handle("DELETE", "/v1/customers/1");
|
|
2705
|
+
expect((await mock.handle("GET", "/v1/customers")).body).toHaveLength(1);
|
|
2706
|
+
expect((await mock.handle("GET", "/v1/products")).body).toHaveLength(1);
|
|
2707
|
+
}, 120_000);
|
|
2708
|
+
|
|
2709
|
+
it("non-CRUD endpoints respond alongside CRUD", async () => {
|
|
2710
|
+
const mock = schmock({ state: {} });
|
|
2711
|
+
mock.pipe(plugin);
|
|
2712
|
+
|
|
2713
|
+
// Singleton endpoint — /v1/balance (non-CRUD static)
|
|
2714
|
+
const balance = await mock.handle("GET", "/v1/balance");
|
|
2715
|
+
expect(balance.status).toBe(200);
|
|
2716
|
+
|
|
2717
|
+
// CRUD still works
|
|
2718
|
+
const created = await mock.handle("POST", "/v1/customers", {
|
|
2719
|
+
body: { email: "test@stripe.com" },
|
|
2720
|
+
});
|
|
2721
|
+
expect(created.status).toBe(201);
|
|
2722
|
+
}, 120_000);
|
|
2723
|
+
|
|
2724
|
+
it("resetState clears all Stripe resources", async () => {
|
|
2725
|
+
const mock = schmock({ state: {} });
|
|
2726
|
+
mock.pipe(plugin);
|
|
2727
|
+
|
|
2728
|
+
await mock.handle("POST", "/v1/customers", {
|
|
2729
|
+
body: { email: "temp@stripe.com" },
|
|
2730
|
+
});
|
|
2731
|
+
expect((await mock.handle("GET", "/v1/customers")).body).toHaveLength(1);
|
|
2732
|
+
|
|
2733
|
+
mock.resetState();
|
|
2734
|
+
|
|
2735
|
+
const list = await mock.handle("GET", "/v1/customers");
|
|
2736
|
+
expect(list.body).toEqual([]);
|
|
2737
|
+
}, 120_000);
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
describe("stress: stripe spec — the confused Stripe developer", () => {
|
|
2741
|
+
let plugin: Schmock.Plugin;
|
|
2742
|
+
|
|
2743
|
+
beforeAll(async () => {
|
|
2744
|
+
plugin = await getStripePlugin();
|
|
2745
|
+
}, 120_000);
|
|
2746
|
+
|
|
2747
|
+
it("reads customer with Stripe-format ID (string) before any creates", async () => {
|
|
2748
|
+
const mock = schmock({ state: {} });
|
|
2749
|
+
mock.pipe(plugin);
|
|
2750
|
+
|
|
2751
|
+
// Stripe IDs look like "cus_QXg1o8vcGmoR32" — should 404 gracefully
|
|
2752
|
+
const read = await mock.handle("GET", "/v1/customers/cus_QXg1o8vcGmoR32");
|
|
2753
|
+
expect(read.status).toBe(404);
|
|
2754
|
+
}, 120_000);
|
|
2755
|
+
|
|
2756
|
+
it("rapid customer creation — 50 customers with fixture-like data", async () => {
|
|
2757
|
+
const mock = schmock({ state: {} });
|
|
2758
|
+
mock.pipe(plugin);
|
|
2759
|
+
|
|
2760
|
+
const emails = Array.from(
|
|
2761
|
+
{ length: 50 },
|
|
2762
|
+
(_, i) => `user${i}@stripe-test.com`,
|
|
2763
|
+
);
|
|
2764
|
+
|
|
2765
|
+
for (const email of emails) {
|
|
2766
|
+
const res = await mock.handle("POST", "/v1/customers", {
|
|
2767
|
+
body: { email, name: `User ${email.split("@")[0]}` },
|
|
2768
|
+
});
|
|
2769
|
+
expect(res.status).toBe(201);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
const list = await mock.handle("GET", "/v1/customers");
|
|
2773
|
+
expect(list.body).toHaveLength(50);
|
|
2774
|
+
}, 120_000);
|
|
2775
|
+
|
|
2776
|
+
it("interleaved operations across Stripe resources", async () => {
|
|
2777
|
+
const mock = schmock({ state: {} });
|
|
2778
|
+
mock.pipe(plugin);
|
|
2779
|
+
|
|
2780
|
+
// Create customer → create product → create coupon → read customer → delete product
|
|
2781
|
+
const c1 = await mock.handle("POST", "/v1/customers", {
|
|
2782
|
+
body: { email: "x@test.com" },
|
|
2783
|
+
});
|
|
2784
|
+
const p1 = await mock.handle("POST", "/v1/products", {
|
|
2785
|
+
body: { name: "Widget" },
|
|
2786
|
+
});
|
|
2787
|
+
const co1 = await mock.handle("POST", "/v1/coupons", {
|
|
2788
|
+
body: { percent_off: 10 },
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
// Read back customer
|
|
2792
|
+
const custId = (c1.body as Record<string, unknown>).customer;
|
|
2793
|
+
const read = await mock.handle("GET", `/v1/customers/${custId}`);
|
|
2794
|
+
expect(read.status).toBe(200);
|
|
2795
|
+
|
|
2796
|
+
// Delete product — idParam is "id" for products
|
|
2797
|
+
const prodId = (p1.body as Record<string, unknown>).id;
|
|
2798
|
+
await mock.handle("DELETE", `/v1/products/${prodId}`);
|
|
2799
|
+
|
|
2800
|
+
// Customer and coupon still exist
|
|
2801
|
+
expect((await mock.handle("GET", `/v1/customers/${custId}`)).status).toBe(
|
|
2802
|
+
200,
|
|
2803
|
+
);
|
|
2804
|
+
const couponId = (co1.body as Record<string, unknown>).coupon;
|
|
2805
|
+
expect((await mock.handle("GET", `/v1/coupons/${couponId}`)).status).toBe(
|
|
2806
|
+
200,
|
|
2807
|
+
);
|
|
2808
|
+
|
|
2809
|
+
// Product gone
|
|
2810
|
+
expect((await mock.handle("GET", `/v1/products/${prodId}`)).status).toBe(
|
|
2811
|
+
404,
|
|
2812
|
+
);
|
|
2813
|
+
}, 120_000);
|
|
2814
|
+
});
|