api-json-server 1.0.1 → 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/README.md +286 -0
- package/dist/behavior.js +32 -0
- package/dist/index.js +115 -5
- package/dist/loadSpec.js +32 -0
- package/dist/openapi.js +152 -0
- package/dist/registerEndpoints.js +80 -0
- package/dist/requestMatch.js +60 -0
- package/dist/responseRenderer.js +98 -0
- package/dist/server.js +150 -0
- package/dist/spec.js +110 -0
- package/dist/stringTemplate.js +55 -0
- package/examples/auth-variants.json +31 -0
- package/examples/basic-crud.json +46 -0
- package/examples/companies-nested.json +47 -0
- package/examples/orders-and-matches.json +49 -0
- package/examples/users-faker.json +35 -0
- package/mock.spec.json +1 -0
- package/mockserve.spec.schema.json +7 -0
- package/package.json +15 -3
- package/scripts/build-schema.ts +21 -0
- package/src/behavior.ts +42 -0
- package/src/index.ts +108 -82
- package/src/loadSpec.ts +5 -2
- package/src/openapi.ts +203 -0
- package/src/registerEndpoints.ts +77 -163
- package/src/requestMatch.ts +62 -0
- package/src/responseRenderer.ts +112 -0
- package/src/server.ts +164 -15
- package/src/spec.ts +70 -8
- package/src/stringTemplate.ts +55 -0
- package/tests/behavior.test.ts +88 -0
- package/tests/faker.test.ts +175 -0
- package/tests/fixtures/spec.basic.json +39 -0
- package/tests/helpers.ts +28 -0
- package/tests/matching.test.ts +136 -0
- package/tests/server.test.ts +73 -0
- package/tests/template.test.ts +90 -0
- package/src/template.ts +0 -61
package/src/spec.ts
CHANGED
|
@@ -1,11 +1,72 @@
|
|
|
1
1
|
import * as z from 'zod'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
export type Primitive = string | number | boolean;
|
|
4
|
+
|
|
5
|
+
const PrimitiveSchema = z.union([z.string(), z.number(), z.boolean()]);
|
|
6
|
+
|
|
7
|
+
export interface TemplateArray extends Array<TemplateValue> {}
|
|
8
|
+
|
|
9
|
+
export interface TemplateObject {
|
|
10
|
+
[key: string]: TemplateValue;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type FakerTemplate = {
|
|
14
|
+
__faker: string | { method: string; args?: TemplateValue[] };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RepeatTemplate = {
|
|
18
|
+
__repeat: {
|
|
19
|
+
min?: number;
|
|
20
|
+
max?: number;
|
|
21
|
+
count?: number;
|
|
22
|
+
template: TemplateValue;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type TemplateValue =
|
|
27
|
+
| Primitive
|
|
28
|
+
| null
|
|
29
|
+
| TemplateArray
|
|
30
|
+
| TemplateObject
|
|
31
|
+
| FakerTemplate
|
|
32
|
+
| RepeatTemplate;
|
|
33
|
+
|
|
34
|
+
const TemplateValueSchema: z.ZodType<TemplateValue> = z.lazy(() =>
|
|
35
|
+
z.union([
|
|
36
|
+
PrimitiveSchema,
|
|
37
|
+
z.null(),
|
|
38
|
+
z.array(TemplateValueSchema),
|
|
39
|
+
z.object({ __faker: z.string().min(1) }).strict(),
|
|
40
|
+
z
|
|
41
|
+
.object({
|
|
42
|
+
__faker: z
|
|
43
|
+
.object({
|
|
44
|
+
method: z.string().min(1),
|
|
45
|
+
args: z.array(TemplateValueSchema).optional()
|
|
46
|
+
})
|
|
47
|
+
.strict()
|
|
48
|
+
})
|
|
49
|
+
.strict(),
|
|
50
|
+
z
|
|
51
|
+
.object({
|
|
52
|
+
__repeat: z
|
|
53
|
+
.object({
|
|
54
|
+
min: z.number().int().min(0).optional(),
|
|
55
|
+
max: z.number().int().min(0).optional(),
|
|
56
|
+
count: z.number().int().min(0).optional(),
|
|
57
|
+
template: TemplateValueSchema
|
|
58
|
+
})
|
|
59
|
+
.strict()
|
|
60
|
+
})
|
|
61
|
+
.strict(),
|
|
62
|
+
z.record(z.string(), TemplateValueSchema)
|
|
63
|
+
])
|
|
64
|
+
);
|
|
4
65
|
|
|
5
66
|
export const MatchSchema = z.object({
|
|
6
|
-
query: z.record(z.string(),
|
|
67
|
+
query: z.record(z.string(), PrimitiveSchema).optional(),
|
|
7
68
|
// Exact match for top-level body fields only (keeps v1 simple)
|
|
8
|
-
body: z.record(z.string(),
|
|
69
|
+
body: z.record(z.string(), PrimitiveSchema).optional()
|
|
9
70
|
});
|
|
10
71
|
|
|
11
72
|
export const VariantSchema = z.object({
|
|
@@ -13,13 +74,13 @@ export const VariantSchema = z.object({
|
|
|
13
74
|
match: MatchSchema.optional(),
|
|
14
75
|
|
|
15
76
|
status: z.number().int().min(100).max(599).optional(),
|
|
16
|
-
response:
|
|
77
|
+
response: TemplateValueSchema,
|
|
17
78
|
|
|
18
79
|
// Simulation overrides per variant (optional)
|
|
19
80
|
delayMs: z.number().int().min(0).optional(),
|
|
20
81
|
errorRate: z.number().min(0).max(1).optional(),
|
|
21
82
|
errorStatus: z.number().int().min(100).max(599).optional(),
|
|
22
|
-
errorResponse:
|
|
83
|
+
errorResponse: TemplateValueSchema.optional()
|
|
23
84
|
});
|
|
24
85
|
|
|
25
86
|
export const EndpointSchema = z.object({
|
|
@@ -31,13 +92,13 @@ export const EndpointSchema = z.object({
|
|
|
31
92
|
|
|
32
93
|
// Response behavior:
|
|
33
94
|
status: z.number().int().min(200).max(599).default(200),
|
|
34
|
-
response:
|
|
95
|
+
response: TemplateValueSchema,
|
|
35
96
|
|
|
36
97
|
// Simulation (optional overrides)
|
|
37
98
|
delayMs: z.number().int().min(0).optional(),
|
|
38
99
|
errorRate: z.number().min(0).max(1).optional(),
|
|
39
100
|
errorStatus: z.number().int().min(100).max(599).optional(),
|
|
40
|
-
errorResponse:
|
|
101
|
+
errorResponse: TemplateValueSchema.optional()
|
|
41
102
|
})
|
|
42
103
|
|
|
43
104
|
export const MockSpecSchema = z.object({
|
|
@@ -47,7 +108,8 @@ export const MockSpecSchema = z.object({
|
|
|
47
108
|
delayMs: z.number().int().min(0).default(0),
|
|
48
109
|
errorRate: z.number().min(0).max(1).default(0),
|
|
49
110
|
errorStatus: z.number().int().min(100).max(599).default(500),
|
|
50
|
-
errorResponse:
|
|
111
|
+
errorResponse: TemplateValueSchema.default({ error: "Mock error" }),
|
|
112
|
+
fakerSeed: z.number().int().min(0).optional()
|
|
51
113
|
})
|
|
52
114
|
.default({ delayMs: 0, errorRate: 0, errorStatus: 500, errorResponse: { error: "Mock error" } }),
|
|
53
115
|
endpoints: z.array(EndpointSchema).min(1)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type TemplateContext = {
|
|
2
|
+
params: Record<string, unknown>;
|
|
3
|
+
query: Record<string, unknown>;
|
|
4
|
+
body: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Safely read a nested property from an object using a dot path.
|
|
9
|
+
*/
|
|
10
|
+
export function getPathValue(obj: unknown, path: string): unknown {
|
|
11
|
+
if (!path) return undefined;
|
|
12
|
+
|
|
13
|
+
const parts = path.split(".");
|
|
14
|
+
let current: unknown = obj;
|
|
15
|
+
|
|
16
|
+
for (const part of parts) {
|
|
17
|
+
if (current === null || current === undefined) return undefined;
|
|
18
|
+
if (typeof current !== "object") return undefined;
|
|
19
|
+
|
|
20
|
+
const record = current as Record<string, unknown>;
|
|
21
|
+
current = record[part];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render string templates like {{params.id}}, {{query.type}}, {{body.email}}.
|
|
29
|
+
*/
|
|
30
|
+
export function renderStringTemplate(input: string, ctx: TemplateContext): string {
|
|
31
|
+
/**
|
|
32
|
+
* Replace a single template token with its resolved value.
|
|
33
|
+
*/
|
|
34
|
+
function replaceToken(_match: string, root: string, path: string): string {
|
|
35
|
+
let source: unknown;
|
|
36
|
+
if (root === "params") source = ctx.params;
|
|
37
|
+
else if (root === "query") source = ctx.query;
|
|
38
|
+
else if (root === "body") source = ctx.body;
|
|
39
|
+
else return "";
|
|
40
|
+
|
|
41
|
+
const value = getPathValue(source, path);
|
|
42
|
+
|
|
43
|
+
if (value === undefined || value === null) return "";
|
|
44
|
+
if (typeof value === "string") return value;
|
|
45
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
} catch {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return input.replace(/\{\{\s*([a-zA-Z]+)\.([a-zA-Z0-9_.]+)\s*\}\}/g, replaceToken);
|
|
55
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildTestServer } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
const baseSettings = {
|
|
5
|
+
delayMs: 0,
|
|
6
|
+
errorRate: 0,
|
|
7
|
+
errorStatus: 500,
|
|
8
|
+
errorResponse: { error: "Mock error" }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("behavior settings", () => {
|
|
12
|
+
it("uses error response when errorRate is 1", async () => {
|
|
13
|
+
const app = buildTestServer({
|
|
14
|
+
version: 1,
|
|
15
|
+
settings: { ...baseSettings, errorRate: 1 },
|
|
16
|
+
endpoints: [
|
|
17
|
+
{
|
|
18
|
+
method: "GET",
|
|
19
|
+
path: "/always-fail",
|
|
20
|
+
response: { ok: true },
|
|
21
|
+
errorStatus: 503,
|
|
22
|
+
errorResponse: { message: "Service down" }
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const res = await app.inject({ method: "GET", url: "/always-fail" });
|
|
28
|
+
expect(res.statusCode).toBe(503);
|
|
29
|
+
expect(res.json()).toEqual({ message: "Service down" });
|
|
30
|
+
|
|
31
|
+
await app.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders templates in error responses", async () => {
|
|
35
|
+
const app = buildTestServer({
|
|
36
|
+
version: 1,
|
|
37
|
+
settings: { ...baseSettings, errorRate: 1 },
|
|
38
|
+
endpoints: [
|
|
39
|
+
{
|
|
40
|
+
method: "GET",
|
|
41
|
+
path: "/users/:id",
|
|
42
|
+
response: { ok: true },
|
|
43
|
+
errorStatus: 400,
|
|
44
|
+
errorResponse: { message: "User {{params.id}} missing" }
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const res = await app.inject({ method: "GET", url: "/users/55" });
|
|
50
|
+
expect(res.statusCode).toBe(400);
|
|
51
|
+
expect(res.json()).toEqual({ message: "User 55 missing" });
|
|
52
|
+
|
|
53
|
+
await app.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("uses variant-level status codes", async () => {
|
|
57
|
+
const app = buildTestServer({
|
|
58
|
+
version: 1,
|
|
59
|
+
settings: baseSettings,
|
|
60
|
+
endpoints: [
|
|
61
|
+
{
|
|
62
|
+
method: "POST",
|
|
63
|
+
path: "/login",
|
|
64
|
+
response: { ok: true },
|
|
65
|
+
variants: [
|
|
66
|
+
{
|
|
67
|
+
name: "unauthorized",
|
|
68
|
+
match: { body: { password: "wrong" } },
|
|
69
|
+
status: 401,
|
|
70
|
+
response: { ok: false }
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const res = await app.inject({
|
|
78
|
+
method: "POST",
|
|
79
|
+
url: "/login",
|
|
80
|
+
payload: { password: "wrong" }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(res.statusCode).toBe(401);
|
|
84
|
+
expect(res.json()).toEqual({ ok: false });
|
|
85
|
+
|
|
86
|
+
await app.close();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildTestServer } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
const baseSettings = {
|
|
5
|
+
delayMs: 0,
|
|
6
|
+
errorRate: 0,
|
|
7
|
+
errorStatus: 500,
|
|
8
|
+
errorResponse: { error: "Mock error" }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("faker templates", () => {
|
|
12
|
+
it("generates deterministic faker data when fakerSeed is set", async () => {
|
|
13
|
+
const app = buildTestServer({
|
|
14
|
+
version: 1,
|
|
15
|
+
settings: { ...baseSettings, fakerSeed: 123 },
|
|
16
|
+
endpoints: [
|
|
17
|
+
{
|
|
18
|
+
method: "GET",
|
|
19
|
+
path: "/profile",
|
|
20
|
+
response: {
|
|
21
|
+
firstName: { __faker: "person.firstName" },
|
|
22
|
+
lastName: { __faker: "person.lastName" },
|
|
23
|
+
email: { __faker: "internet.email" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const res1 = await app.inject({ method: "GET", url: "/profile" });
|
|
30
|
+
const res2 = await app.inject({ method: "GET", url: "/profile" });
|
|
31
|
+
|
|
32
|
+
expect(res1.statusCode).toBe(200);
|
|
33
|
+
expect(res2.statusCode).toBe(200);
|
|
34
|
+
expect(res1.json()).toEqual(res2.json());
|
|
35
|
+
|
|
36
|
+
await app.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("supports faker methods with arguments", async () => {
|
|
40
|
+
const app = buildTestServer({
|
|
41
|
+
version: 1,
|
|
42
|
+
settings: { ...baseSettings, fakerSeed: 7 },
|
|
43
|
+
endpoints: [
|
|
44
|
+
{
|
|
45
|
+
method: "GET",
|
|
46
|
+
path: "/token",
|
|
47
|
+
response: {
|
|
48
|
+
token: { __faker: { method: "string.alpha", args: [16] } }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const res = await app.inject({ method: "GET", url: "/token" });
|
|
55
|
+
const body = res.json();
|
|
56
|
+
|
|
57
|
+
expect(res.statusCode).toBe(200);
|
|
58
|
+
expect(typeof body.token).toBe("string");
|
|
59
|
+
expect(body.token).toHaveLength(16);
|
|
60
|
+
|
|
61
|
+
await app.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders repeat directives with a min/max range", async () => {
|
|
65
|
+
const app = buildTestServer({
|
|
66
|
+
version: 1,
|
|
67
|
+
settings: { ...baseSettings, fakerSeed: 55 },
|
|
68
|
+
endpoints: [
|
|
69
|
+
{
|
|
70
|
+
method: "GET",
|
|
71
|
+
path: "/users",
|
|
72
|
+
response: {
|
|
73
|
+
users: {
|
|
74
|
+
__repeat: {
|
|
75
|
+
min: 10,
|
|
76
|
+
max: 15,
|
|
77
|
+
template: {
|
|
78
|
+
id: { __faker: "string.uuid" },
|
|
79
|
+
firstName: { __faker: "person.firstName" },
|
|
80
|
+
email: { __faker: "internet.email" }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const res = await app.inject({ method: "GET", url: "/users" });
|
|
90
|
+
const body = res.json();
|
|
91
|
+
|
|
92
|
+
expect(res.statusCode).toBe(200);
|
|
93
|
+
expect(Array.isArray(body.users)).toBe(true);
|
|
94
|
+
expect(body.users.length).toBeGreaterThanOrEqual(10);
|
|
95
|
+
expect(body.users.length).toBeLessThanOrEqual(15);
|
|
96
|
+
expect(typeof body.users[0].firstName).toBe("string");
|
|
97
|
+
|
|
98
|
+
await app.close();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("renders repeat directives with a fixed count", async () => {
|
|
102
|
+
const app = buildTestServer({
|
|
103
|
+
version: 1,
|
|
104
|
+
settings: { ...baseSettings, fakerSeed: 1 },
|
|
105
|
+
endpoints: [
|
|
106
|
+
{
|
|
107
|
+
method: "GET",
|
|
108
|
+
path: "/companies",
|
|
109
|
+
response: {
|
|
110
|
+
companies: {
|
|
111
|
+
__repeat: {
|
|
112
|
+
count: 3,
|
|
113
|
+
min: 0,
|
|
114
|
+
template: {
|
|
115
|
+
name: { __faker: "company.name" },
|
|
116
|
+
phone: { __faker: "phone.number" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const res = await app.inject({ method: "GET", url: "/companies" });
|
|
126
|
+
const body = res.json();
|
|
127
|
+
|
|
128
|
+
expect(res.statusCode).toBe(200);
|
|
129
|
+
expect(body.companies).toHaveLength(3);
|
|
130
|
+
|
|
131
|
+
await app.close();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns 500 when faker method is invalid", async () => {
|
|
135
|
+
const app = buildTestServer({
|
|
136
|
+
version: 1,
|
|
137
|
+
settings: baseSettings,
|
|
138
|
+
endpoints: [
|
|
139
|
+
{
|
|
140
|
+
method: "GET",
|
|
141
|
+
path: "/bad",
|
|
142
|
+
response: { value: { __faker: "nope.method" } }
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const res = await app.inject({ method: "GET", url: "/bad" });
|
|
148
|
+
expect(res.statusCode).toBe(500);
|
|
149
|
+
|
|
150
|
+
await app.close();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns 500 when repeat max is below min", async () => {
|
|
154
|
+
const app = buildTestServer({
|
|
155
|
+
version: 1,
|
|
156
|
+
settings: baseSettings,
|
|
157
|
+
endpoints: [
|
|
158
|
+
{
|
|
159
|
+
method: "GET",
|
|
160
|
+
path: "/bad-repeat",
|
|
161
|
+
response: {
|
|
162
|
+
items: {
|
|
163
|
+
__repeat: { min: 5, max: 2, template: { id: 1 } }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const res = await app.inject({ method: "GET", url: "/bad-repeat" });
|
|
171
|
+
expect(res.statusCode).toBe(500);
|
|
172
|
+
|
|
173
|
+
await app.close();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"settings": {
|
|
4
|
+
"delayMs": 0,
|
|
5
|
+
"errorRate": 0,
|
|
6
|
+
"errorStatus": 500,
|
|
7
|
+
"errorResponse": { "error": "Mock error" }
|
|
8
|
+
},
|
|
9
|
+
"endpoints": [
|
|
10
|
+
{
|
|
11
|
+
"method": "GET",
|
|
12
|
+
"path": "/users/:id",
|
|
13
|
+
"status": 200,
|
|
14
|
+
"response": { "id": "{{params.id}}", "type": "{{query.type}}"}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"method": "GET",
|
|
18
|
+
"path": "/search",
|
|
19
|
+
"status": 200,
|
|
20
|
+
"match": { "query": { "type": "premium" } },
|
|
21
|
+
"response": { "ok": true, "tier": "premium" }
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"method": "POST",
|
|
25
|
+
"path": "/login",
|
|
26
|
+
"status": 200,
|
|
27
|
+
"response": { "ok": true, "message": "default" },
|
|
28
|
+
"variants": [
|
|
29
|
+
{
|
|
30
|
+
"name": "wrong password",
|
|
31
|
+
"match": { "body": { "password": "wrong" } },
|
|
32
|
+
"status": 401,
|
|
33
|
+
"response": { "ok": false, "error": "Invalid credentials" }
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { FastifyInstance } from "fastify";
|
|
3
|
+
import { buildServer } from "../src/server.js";
|
|
4
|
+
import { MockSpecSchema, type MockSpecInferSchema } from "../src/spec.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load and validate a fixture spec file.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadFixture(path: string): Promise<MockSpecInferSchema> {
|
|
10
|
+
const raw = await readFile(path, "utf-8");
|
|
11
|
+
const json = JSON.parse(raw) as unknown;
|
|
12
|
+
return MockSpecSchema.parse(json);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a Fastify instance from a fixture spec file.
|
|
17
|
+
*/
|
|
18
|
+
export async function buildTestServerFromFixture(path: string): Promise<FastifyInstance> {
|
|
19
|
+
const spec = await loadFixture(path);
|
|
20
|
+
return buildServer(spec, { specPath: path, loadedAt: "now" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a Fastify instance from an in-memory spec object.
|
|
25
|
+
*/
|
|
26
|
+
export function buildTestServer(spec: MockSpecInferSchema): FastifyInstance {
|
|
27
|
+
return buildServer(spec, { specPath: "inline", loadedAt: "now" });
|
|
28
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildTestServer } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
const baseSettings = {
|
|
5
|
+
delayMs: 0,
|
|
6
|
+
errorRate: 0,
|
|
7
|
+
errorStatus: 500,
|
|
8
|
+
errorResponse: { error: "Mock error" }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("matching rules", () => {
|
|
12
|
+
it("returns 404 when endpoint-level query match fails", async () => {
|
|
13
|
+
const app = buildTestServer({
|
|
14
|
+
version: 1,
|
|
15
|
+
settings: baseSettings,
|
|
16
|
+
endpoints: [
|
|
17
|
+
{
|
|
18
|
+
method: "GET",
|
|
19
|
+
path: "/search",
|
|
20
|
+
match: { query: { type: "premium" } },
|
|
21
|
+
response: { ok: true }
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const res = await app.inject({ method: "GET", url: "/search?type=basic" });
|
|
27
|
+
expect(res.statusCode).toBe(404);
|
|
28
|
+
|
|
29
|
+
await app.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("matches endpoint-level body rules for POST", async () => {
|
|
33
|
+
const app = buildTestServer({
|
|
34
|
+
version: 1,
|
|
35
|
+
settings: baseSettings,
|
|
36
|
+
endpoints: [
|
|
37
|
+
{
|
|
38
|
+
method: "POST",
|
|
39
|
+
path: "/login",
|
|
40
|
+
match: { body: { password: "secret" } },
|
|
41
|
+
response: { ok: true }
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ok = await app.inject({ method: "POST", url: "/login", payload: { password: "secret" } });
|
|
47
|
+
expect(ok.statusCode).toBe(200);
|
|
48
|
+
|
|
49
|
+
const bad = await app.inject({ method: "POST", url: "/login", payload: { password: "wrong" } });
|
|
50
|
+
expect(bad.statusCode).toBe(404);
|
|
51
|
+
|
|
52
|
+
await app.close();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("uses first matching variant when multiple variants match", async () => {
|
|
56
|
+
const app = buildTestServer({
|
|
57
|
+
version: 1,
|
|
58
|
+
settings: baseSettings,
|
|
59
|
+
endpoints: [
|
|
60
|
+
{
|
|
61
|
+
method: "POST",
|
|
62
|
+
path: "/variants",
|
|
63
|
+
response: { ok: true, source: "base" },
|
|
64
|
+
variants: [
|
|
65
|
+
{
|
|
66
|
+
name: "first",
|
|
67
|
+
match: { body: { role: "admin" } },
|
|
68
|
+
status: 403,
|
|
69
|
+
response: { ok: false, source: "first" }
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "second",
|
|
73
|
+
match: { body: { role: "admin" } },
|
|
74
|
+
status: 200,
|
|
75
|
+
response: { ok: true, source: "second" }
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const res = await app.inject({ method: "POST", url: "/variants", payload: { role: "admin" } });
|
|
83
|
+
expect(res.statusCode).toBe(403);
|
|
84
|
+
expect(res.json()).toEqual({ ok: false, source: "first" });
|
|
85
|
+
|
|
86
|
+
await app.close();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to endpoint response when no variant matches", async () => {
|
|
90
|
+
const app = buildTestServer({
|
|
91
|
+
version: 1,
|
|
92
|
+
settings: baseSettings,
|
|
93
|
+
endpoints: [
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
path: "/variants",
|
|
97
|
+
response: { ok: true, source: "base" },
|
|
98
|
+
variants: [
|
|
99
|
+
{
|
|
100
|
+
name: "only",
|
|
101
|
+
match: { body: { role: "admin" } },
|
|
102
|
+
status: 201,
|
|
103
|
+
response: { ok: true, source: "variant" }
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const res = await app.inject({ method: "POST", url: "/variants", payload: { role: "user" } });
|
|
111
|
+
expect(res.statusCode).toBe(200);
|
|
112
|
+
expect(res.json()).toEqual({ ok: true, source: "base" });
|
|
113
|
+
|
|
114
|
+
await app.close();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects array query values for match rules", async () => {
|
|
118
|
+
const app = buildTestServer({
|
|
119
|
+
version: 1,
|
|
120
|
+
settings: baseSettings,
|
|
121
|
+
endpoints: [
|
|
122
|
+
{
|
|
123
|
+
method: "GET",
|
|
124
|
+
path: "/search",
|
|
125
|
+
match: { query: { type: "premium" } },
|
|
126
|
+
response: { ok: true }
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const res = await app.inject({ method: "GET", url: "/search?type=premium&type=basic" });
|
|
132
|
+
expect(res.statusCode).toBe(404);
|
|
133
|
+
|
|
134
|
+
await app.close();
|
|
135
|
+
});
|
|
136
|
+
});
|