@typokit/testing 0.1.4
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/contract-generator.d.ts +65 -0
- package/dist/contract-generator.d.ts.map +1 -0
- package/dist/contract-generator.js +325 -0
- package/dist/contract-generator.js.map +1 -0
- package/dist/factory.d.ts +27 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +194 -0
- package/dist/factory.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/integration-suite.d.ts +66 -0
- package/dist/integration-suite.d.ts.map +1 -0
- package/dist/integration-suite.js +110 -0
- package/dist/integration-suite.js.map +1 -0
- package/dist/schema-matcher.d.ts +78 -0
- package/dist/schema-matcher.d.ts.map +1 -0
- package/dist/schema-matcher.js +99 -0
- package/dist/schema-matcher.js.map +1 -0
- package/package.json +34 -0
- package/src/contract-generator.test.ts +356 -0
- package/src/contract-generator.ts +412 -0
- package/src/factory.test.ts +217 -0
- package/src/factory.ts +248 -0
- package/src/index.test.ts +275 -0
- package/src/index.ts +284 -0
- package/src/integration-suite.test.ts +336 -0
- package/src/integration-suite.ts +191 -0
- package/src/schema-matcher.test.ts +293 -0
- package/src/schema-matcher.ts +160 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// @typokit/testing — Contract Test Generation
|
|
2
|
+
//
|
|
3
|
+
// Auto-generates baseline contract tests from route schemas.
|
|
4
|
+
// Output is test-runner-agnostic (Jest, Vitest, Rstest).
|
|
5
|
+
|
|
6
|
+
import type { HttpMethod, TypeMetadata, SchemaTypeMap } from "@typokit/types";
|
|
7
|
+
|
|
8
|
+
// ─── Types ────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** A route definition used for contract test generation */
|
|
11
|
+
export interface ContractTestRoute {
|
|
12
|
+
/** HTTP method */
|
|
13
|
+
method: HttpMethod;
|
|
14
|
+
/** Route path (e.g. "/users/:id") */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Handler reference */
|
|
17
|
+
handlerRef: string;
|
|
18
|
+
/** Validator schema references */
|
|
19
|
+
validators?: {
|
|
20
|
+
params?: string;
|
|
21
|
+
query?: string;
|
|
22
|
+
body?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Response schema name (for toMatchSchema assertions) */
|
|
25
|
+
responseSchema?: string;
|
|
26
|
+
/** Expected success status code (default: 200) */
|
|
27
|
+
expectedStatus?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Supported test runners */
|
|
31
|
+
export type TestRunner = "jest" | "vitest" | "rstest";
|
|
32
|
+
|
|
33
|
+
/** Options for contract test generation */
|
|
34
|
+
export interface ContractTestOptions {
|
|
35
|
+
/** Test runner to generate imports for */
|
|
36
|
+
runner: TestRunner;
|
|
37
|
+
/** Import path for the app module (e.g. "../src/app") */
|
|
38
|
+
appImport: string;
|
|
39
|
+
/** Routes to generate tests for */
|
|
40
|
+
routes: ContractTestRoute[];
|
|
41
|
+
/** Schema type metadata for validators */
|
|
42
|
+
schemas: SchemaTypeMap;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A generated contract test file */
|
|
46
|
+
export interface ContractTestOutput {
|
|
47
|
+
/** Relative file path (e.g. "__generated__/users.contract.test.ts") */
|
|
48
|
+
filePath: string;
|
|
49
|
+
/** Generated file content */
|
|
50
|
+
content: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Helpers ──────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** Deterministic seed-based data generator for inline test values */
|
|
56
|
+
function generateSampleValue(
|
|
57
|
+
type: string,
|
|
58
|
+
jsdoc?: Record<string, string>,
|
|
59
|
+
): unknown {
|
|
60
|
+
// Check for format constraints
|
|
61
|
+
const format = jsdoc?.["format"] ?? jsdoc?.["@format"];
|
|
62
|
+
if (format === "email") return "test@example.com";
|
|
63
|
+
if (format === "url") return "https://example.com";
|
|
64
|
+
if (format === "uuid") return "550e8400-e29b-41d4-a716-446655440000";
|
|
65
|
+
if (format === "date-time") return "2026-01-01T00:00:00.000Z";
|
|
66
|
+
|
|
67
|
+
// Check for string unions
|
|
68
|
+
if (type.includes("|") && type.includes('"')) {
|
|
69
|
+
const values = type.split("|").map((v) => v.trim().replace(/^"|"$/g, ""));
|
|
70
|
+
return values[0];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (type === "string") return "test-value";
|
|
74
|
+
if (type === "number") return 42;
|
|
75
|
+
if (type === "boolean") return true;
|
|
76
|
+
if (type === "string[]") return ["test-item"];
|
|
77
|
+
if (type === "number[]") return [1];
|
|
78
|
+
if (type.endsWith("[]")) return [];
|
|
79
|
+
|
|
80
|
+
return "test-value";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Generate an invalid value for a given type */
|
|
84
|
+
function generateInvalidSampleValue(
|
|
85
|
+
type: string,
|
|
86
|
+
jsdoc?: Record<string, string>,
|
|
87
|
+
): unknown {
|
|
88
|
+
const format = jsdoc?.["format"] ?? jsdoc?.["@format"];
|
|
89
|
+
if (format === "email") return "not-an-email";
|
|
90
|
+
if (format === "url") return "not-a-url";
|
|
91
|
+
if (format === "uuid") return "not-a-uuid";
|
|
92
|
+
if (format === "date-time") return "not-a-date";
|
|
93
|
+
|
|
94
|
+
if (type === "number") return "not-a-number";
|
|
95
|
+
if (type === "boolean") return "not-a-boolean";
|
|
96
|
+
|
|
97
|
+
// String unions — use a value not in the set
|
|
98
|
+
if (type.includes("|") && type.includes('"')) {
|
|
99
|
+
return "__invalid_enum_value__";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return 12345;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get the test runner import statement */
|
|
106
|
+
function getImportStatement(runner: TestRunner): string {
|
|
107
|
+
switch (runner) {
|
|
108
|
+
case "jest":
|
|
109
|
+
return 'import { describe, it, expect } from "@jest/globals";';
|
|
110
|
+
case "vitest":
|
|
111
|
+
return 'import { describe, it, expect } from "vitest";';
|
|
112
|
+
case "rstest":
|
|
113
|
+
return 'import { describe, it, expect } from "@rstest/core";';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Group routes by path prefix for file organization */
|
|
118
|
+
function groupRoutesByPrefix(
|
|
119
|
+
routes: ContractTestRoute[],
|
|
120
|
+
): Record<string, ContractTestRoute[]> {
|
|
121
|
+
const groups: Record<string, ContractTestRoute[]> = {};
|
|
122
|
+
|
|
123
|
+
for (const route of routes) {
|
|
124
|
+
// Extract first path segment as group name
|
|
125
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
126
|
+
const prefix = segments.length > 0 ? segments[0] : "root";
|
|
127
|
+
if (!groups[prefix]) {
|
|
128
|
+
groups[prefix] = [];
|
|
129
|
+
}
|
|
130
|
+
groups[prefix].push(route);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Sort routes within each group deterministically
|
|
134
|
+
for (const key of Object.keys(groups)) {
|
|
135
|
+
groups[key].sort((a, b) => {
|
|
136
|
+
const methodOrder = a.method.localeCompare(b.method);
|
|
137
|
+
if (methodOrder !== 0) return methodOrder;
|
|
138
|
+
return a.path.localeCompare(b.path);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return groups;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Escape a value for use in generated TypeScript code */
|
|
146
|
+
function toCodeLiteral(value: unknown): string {
|
|
147
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
148
|
+
if (typeof value === "number") return String(value);
|
|
149
|
+
if (typeof value === "boolean") return String(value);
|
|
150
|
+
if (Array.isArray(value)) {
|
|
151
|
+
return `[${value.map(toCodeLiteral).join(", ")}]`;
|
|
152
|
+
}
|
|
153
|
+
return JSON.stringify(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Build a valid body object literal as code string */
|
|
157
|
+
function buildBodyLiteral(schema: TypeMetadata, indent: string): string {
|
|
158
|
+
const entries: string[] = [];
|
|
159
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
160
|
+
if (prop.optional) continue;
|
|
161
|
+
const value = generateSampleValue(prop.type, prop.jsdoc);
|
|
162
|
+
entries.push(`${indent} ${key}: ${toCodeLiteral(value)},`);
|
|
163
|
+
}
|
|
164
|
+
if (entries.length === 0) return "{}";
|
|
165
|
+
return `{\n${entries.join("\n")}\n${indent}}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Generator ────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Generate contract test files from route schemas.
|
|
172
|
+
*
|
|
173
|
+
* Groups routes by path prefix and produces one test file per group.
|
|
174
|
+
* Each file tests: valid input → expected status, missing required
|
|
175
|
+
* fields → 400, invalid field formats → 400.
|
|
176
|
+
*
|
|
177
|
+
* ```ts
|
|
178
|
+
* const outputs = generateContractTests({
|
|
179
|
+
* runner: "vitest",
|
|
180
|
+
* appImport: "../src/app",
|
|
181
|
+
* routes: [{ method: "POST", path: "/users", ... }],
|
|
182
|
+
* schemas: { CreateUserInput: { ... } },
|
|
183
|
+
* });
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function generateContractTests(
|
|
187
|
+
options: ContractTestOptions,
|
|
188
|
+
): ContractTestOutput[] {
|
|
189
|
+
const { runner, appImport, routes, schemas } = options;
|
|
190
|
+
const groups = groupRoutesByPrefix(routes);
|
|
191
|
+
const outputs: ContractTestOutput[] = [];
|
|
192
|
+
|
|
193
|
+
for (const [prefix, groupRoutes] of Object.entries(groups).sort(([a], [b]) =>
|
|
194
|
+
a.localeCompare(b),
|
|
195
|
+
)) {
|
|
196
|
+
const content = generateTestFile(runner, appImport, groupRoutes, schemas);
|
|
197
|
+
outputs.push({
|
|
198
|
+
filePath: `__generated__/${prefix}.contract.test.ts`,
|
|
199
|
+
content,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return outputs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Generate a single contract test file for a group of routes */
|
|
207
|
+
function generateTestFile(
|
|
208
|
+
runner: TestRunner,
|
|
209
|
+
appImport: string,
|
|
210
|
+
routes: ContractTestRoute[],
|
|
211
|
+
schemas: SchemaTypeMap,
|
|
212
|
+
): string {
|
|
213
|
+
const lines: string[] = [];
|
|
214
|
+
|
|
215
|
+
// Header
|
|
216
|
+
lines.push("// DO NOT EDIT — regenerated on schema change");
|
|
217
|
+
lines.push(`// Generated by @typokit/testing contract-generator`);
|
|
218
|
+
lines.push("");
|
|
219
|
+
|
|
220
|
+
// Imports
|
|
221
|
+
lines.push(getImportStatement(runner));
|
|
222
|
+
lines.push(`import { createTestClient } from "@typokit/testing";`);
|
|
223
|
+
|
|
224
|
+
// Only import toMatchSchema if any route has a response schema
|
|
225
|
+
const hasResponseSchema = routes.some((r) => r.responseSchema);
|
|
226
|
+
if (hasResponseSchema) {
|
|
227
|
+
lines.push(`import { toMatchSchema } from "@typokit/testing";`);
|
|
228
|
+
}
|
|
229
|
+
lines.push(`import { app } from ${JSON.stringify(appImport)};`);
|
|
230
|
+
lines.push("");
|
|
231
|
+
|
|
232
|
+
// Setup
|
|
233
|
+
lines.push("let client: Awaited<ReturnType<typeof createTestClient>>;");
|
|
234
|
+
lines.push("");
|
|
235
|
+
lines.push("beforeAll(async () => {");
|
|
236
|
+
lines.push(" client = await createTestClient(app);");
|
|
237
|
+
lines.push("});");
|
|
238
|
+
lines.push("");
|
|
239
|
+
lines.push("afterAll(async () => {");
|
|
240
|
+
lines.push(" await client.close();");
|
|
241
|
+
lines.push("});");
|
|
242
|
+
lines.push("");
|
|
243
|
+
|
|
244
|
+
// Generate test blocks for each route
|
|
245
|
+
for (const route of routes) {
|
|
246
|
+
generateRouteTests(lines, route, schemas);
|
|
247
|
+
lines.push("");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return lines.join("\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Generate describe/it blocks for a single route */
|
|
254
|
+
function generateRouteTests(
|
|
255
|
+
lines: string[],
|
|
256
|
+
route: ContractTestRoute,
|
|
257
|
+
schemas: SchemaTypeMap,
|
|
258
|
+
): void {
|
|
259
|
+
const { method, path, validators, responseSchema, expectedStatus } = route;
|
|
260
|
+
const successStatus = expectedStatus ?? 200;
|
|
261
|
+
const methodLower = method.toLowerCase();
|
|
262
|
+
|
|
263
|
+
lines.push(`describe("${method} ${path}", () => {`);
|
|
264
|
+
|
|
265
|
+
// Resolve body schema if validators reference one
|
|
266
|
+
const bodySchemaName = validators?.body;
|
|
267
|
+
const bodySchema = bodySchemaName ? schemas[bodySchemaName] : undefined;
|
|
268
|
+
|
|
269
|
+
// Determine if the method typically has a body
|
|
270
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(method);
|
|
271
|
+
|
|
272
|
+
// ── Test 1: Valid input → expected status ──
|
|
273
|
+
if (hasBody && bodySchema) {
|
|
274
|
+
const bodyLiteral = buildBodyLiteral(bodySchema, " ");
|
|
275
|
+
lines.push(` it("accepts valid ${bodySchemaName}", async () => {`);
|
|
276
|
+
lines.push(` const res = await client.${methodLower}("${path}", {`);
|
|
277
|
+
lines.push(` body: ${bodyLiteral},`);
|
|
278
|
+
lines.push(` });`);
|
|
279
|
+
lines.push(` expect(res.status).toBe(${successStatus});`);
|
|
280
|
+
if (responseSchema) {
|
|
281
|
+
lines.push(` expect(res.body).toMatchSchema("${responseSchema}");`);
|
|
282
|
+
}
|
|
283
|
+
lines.push(` });`);
|
|
284
|
+
} else {
|
|
285
|
+
lines.push(` it("responds with ${successStatus}", async () => {`);
|
|
286
|
+
lines.push(` const res = await client.${methodLower}("${path}");`);
|
|
287
|
+
lines.push(` expect(res.status).toBe(${successStatus});`);
|
|
288
|
+
if (responseSchema) {
|
|
289
|
+
lines.push(` expect(res.body).toMatchSchema("${responseSchema}");`);
|
|
290
|
+
}
|
|
291
|
+
lines.push(` });`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Test 2: Missing required fields → 400 ──
|
|
295
|
+
if (hasBody && bodySchema) {
|
|
296
|
+
const requiredFields = Object.entries(bodySchema.properties)
|
|
297
|
+
.filter(([, prop]) => !prop.optional)
|
|
298
|
+
.map(([key]) => key)
|
|
299
|
+
.sort();
|
|
300
|
+
|
|
301
|
+
if (requiredFields.length > 0) {
|
|
302
|
+
lines.push("");
|
|
303
|
+
lines.push(` it("rejects missing required fields", async () => {`);
|
|
304
|
+
lines.push(
|
|
305
|
+
` const res = await client.${methodLower}("${path}", { body: {} });`,
|
|
306
|
+
);
|
|
307
|
+
lines.push(` expect(res.status).toBe(400);`);
|
|
308
|
+
lines.push(` });`);
|
|
309
|
+
|
|
310
|
+
// Individual field tests for more specific coverage
|
|
311
|
+
for (const field of requiredFields) {
|
|
312
|
+
lines.push("");
|
|
313
|
+
lines.push(` it("rejects missing '${field}' field", async () => {`);
|
|
314
|
+
// Build a body with all required fields except this one
|
|
315
|
+
const partialEntries: string[] = [];
|
|
316
|
+
for (const [key, prop] of Object.entries(bodySchema.properties)) {
|
|
317
|
+
if (prop.optional || key === field) continue;
|
|
318
|
+
const value = generateSampleValue(prop.type, prop.jsdoc);
|
|
319
|
+
partialEntries.push(` ${key}: ${toCodeLiteral(value)},`);
|
|
320
|
+
}
|
|
321
|
+
const partialBody =
|
|
322
|
+
partialEntries.length > 0
|
|
323
|
+
? `{\n${partialEntries.join("\n")}\n }`
|
|
324
|
+
: "{}";
|
|
325
|
+
lines.push(` const res = await client.${methodLower}("${path}", {`);
|
|
326
|
+
lines.push(` body: ${partialBody},`);
|
|
327
|
+
lines.push(` });`);
|
|
328
|
+
lines.push(` expect(res.status).toBe(400);`);
|
|
329
|
+
lines.push(` });`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Test 3: Invalid field formats → 400 ──
|
|
335
|
+
if (hasBody && bodySchema) {
|
|
336
|
+
const fieldsWithFormats = Object.entries(bodySchema.properties)
|
|
337
|
+
.filter(([, prop]) => {
|
|
338
|
+
const jsdoc = prop.jsdoc;
|
|
339
|
+
if (!jsdoc) return false;
|
|
340
|
+
return !!(jsdoc["format"] || jsdoc["@format"]);
|
|
341
|
+
})
|
|
342
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
343
|
+
|
|
344
|
+
// Also include string unions and typed fields that can be invalidated
|
|
345
|
+
const fieldsWithTypes = Object.entries(bodySchema.properties)
|
|
346
|
+
.filter(([, prop]) => {
|
|
347
|
+
// Already covered by format
|
|
348
|
+
if (prop.jsdoc?.["format"] || prop.jsdoc?.["@format"]) return false;
|
|
349
|
+
return (
|
|
350
|
+
prop.type === "number" ||
|
|
351
|
+
prop.type === "boolean" ||
|
|
352
|
+
(prop.type.includes("|") && prop.type.includes('"'))
|
|
353
|
+
);
|
|
354
|
+
})
|
|
355
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
356
|
+
|
|
357
|
+
const invalidFields = [...fieldsWithFormats, ...fieldsWithTypes];
|
|
358
|
+
|
|
359
|
+
for (const [field, prop] of invalidFields) {
|
|
360
|
+
const invalidValue = generateInvalidSampleValue(prop.type, prop.jsdoc);
|
|
361
|
+
lines.push("");
|
|
362
|
+
lines.push(` it("rejects invalid ${field} format", async () => {`);
|
|
363
|
+
// Build a valid body, then replace the field with invalid value
|
|
364
|
+
const fullEntries: string[] = [];
|
|
365
|
+
for (const [key, p] of Object.entries(bodySchema.properties)) {
|
|
366
|
+
if (p.optional) continue;
|
|
367
|
+
const value =
|
|
368
|
+
key === field ? invalidValue : generateSampleValue(p.type, p.jsdoc);
|
|
369
|
+
fullEntries.push(` ${key}: ${toCodeLiteral(value)},`);
|
|
370
|
+
}
|
|
371
|
+
const fullBody =
|
|
372
|
+
fullEntries.length > 0 ? `{\n${fullEntries.join("\n")}\n }` : "{}";
|
|
373
|
+
lines.push(` const res = await client.${methodLower}("${path}", {`);
|
|
374
|
+
lines.push(` body: ${fullBody},`);
|
|
375
|
+
lines.push(` });`);
|
|
376
|
+
lines.push(` expect(res.status).toBe(400);`);
|
|
377
|
+
lines.push(` });`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
lines.push(`});`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Detect the test runner used in a project by checking for known
|
|
386
|
+
* config files or dependencies.
|
|
387
|
+
*
|
|
388
|
+
* Returns the detected runner or "vitest" as default.
|
|
389
|
+
*/
|
|
390
|
+
export function detectTestRunner(
|
|
391
|
+
packageJson: Record<string, unknown>,
|
|
392
|
+
): TestRunner {
|
|
393
|
+
const deps = {
|
|
394
|
+
...(packageJson["dependencies"] as Record<string, string> | undefined),
|
|
395
|
+
...(packageJson["devDependencies"] as Record<string, string> | undefined),
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
if (deps["rstest"]) return "rstest";
|
|
399
|
+
if (deps["vitest"]) return "vitest";
|
|
400
|
+
if (deps["jest"] || deps["@jest/globals"]) return "jest";
|
|
401
|
+
|
|
402
|
+
// Check scripts for runner hints
|
|
403
|
+
const scripts = packageJson["scripts"] as Record<string, string> | undefined;
|
|
404
|
+
if (scripts) {
|
|
405
|
+
const testScript = scripts["test"] ?? "";
|
|
406
|
+
if (testScript.includes("rstest")) return "rstest";
|
|
407
|
+
if (testScript.includes("vitest")) return "vitest";
|
|
408
|
+
if (testScript.includes("jest")) return "jest";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return "vitest";
|
|
412
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, expect } from "@rstest/core";
|
|
2
|
+
import { createFactory } from "./factory.js";
|
|
3
|
+
import type { TypeMetadata } from "@typokit/types";
|
|
4
|
+
|
|
5
|
+
// ─── Test Fixtures ────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const userMetadata: TypeMetadata = {
|
|
8
|
+
name: "User",
|
|
9
|
+
properties: {
|
|
10
|
+
id: { type: "string", optional: false, jsdoc: { format: "uuid" } },
|
|
11
|
+
email: { type: "string", optional: false, jsdoc: { format: "email" } },
|
|
12
|
+
displayName: {
|
|
13
|
+
type: "string",
|
|
14
|
+
optional: false,
|
|
15
|
+
jsdoc: { minLength: "3", maxLength: "50" },
|
|
16
|
+
},
|
|
17
|
+
age: {
|
|
18
|
+
type: "number",
|
|
19
|
+
optional: true,
|
|
20
|
+
jsdoc: { minimum: "0", maximum: "150" },
|
|
21
|
+
},
|
|
22
|
+
isActive: { type: "boolean", optional: false },
|
|
23
|
+
role: { type: '"admin" | "user" | "moderator"', optional: false },
|
|
24
|
+
website: { type: "string", optional: true, jsdoc: { format: "url" } },
|
|
25
|
+
createdAt: {
|
|
26
|
+
type: "string",
|
|
27
|
+
optional: false,
|
|
28
|
+
jsdoc: { format: "date-time" },
|
|
29
|
+
},
|
|
30
|
+
tags: { type: "string[]", optional: true },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ─── Tests ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe("createFactory", () => {
|
|
37
|
+
it("builds a valid User instance", () => {
|
|
38
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata);
|
|
39
|
+
const user = factory.build();
|
|
40
|
+
|
|
41
|
+
expect(user).toBeDefined();
|
|
42
|
+
expect(typeof user.id).toBe("string");
|
|
43
|
+
expect(typeof user.email).toBe("string");
|
|
44
|
+
expect(typeof user.displayName).toBe("string");
|
|
45
|
+
expect(typeof user.isActive).toBe("boolean");
|
|
46
|
+
expect(typeof user.createdAt).toBe("string");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("generates valid email format", () => {
|
|
50
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
51
|
+
seed: 42,
|
|
52
|
+
});
|
|
53
|
+
const user = factory.build();
|
|
54
|
+
const email = user.email as string;
|
|
55
|
+
expect(email).toContain("@");
|
|
56
|
+
expect(email).toContain(".com");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("generates valid UUID format", () => {
|
|
60
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
61
|
+
seed: 42,
|
|
62
|
+
});
|
|
63
|
+
const user = factory.build();
|
|
64
|
+
const id = user.id as string;
|
|
65
|
+
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
66
|
+
expect(id.split("-").length).toBe(5);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("generates valid URL format", () => {
|
|
70
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
71
|
+
seed: 99,
|
|
72
|
+
});
|
|
73
|
+
const user = factory.build();
|
|
74
|
+
if (user.website !== undefined) {
|
|
75
|
+
const url = user.website as string;
|
|
76
|
+
expect(url).toContain("https://");
|
|
77
|
+
expect(url).toContain(".com");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("respects minLength/maxLength constraints", () => {
|
|
82
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
83
|
+
seed: 42,
|
|
84
|
+
});
|
|
85
|
+
const user = factory.build();
|
|
86
|
+
const name = user.displayName as string;
|
|
87
|
+
expect(name.length).toBeGreaterThanOrEqual(3);
|
|
88
|
+
expect(name.length).toBeLessThanOrEqual(50);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("respects minimum/maximum constraints for numbers", () => {
|
|
92
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
93
|
+
seed: 42,
|
|
94
|
+
});
|
|
95
|
+
const user = factory.build();
|
|
96
|
+
if (user.age !== undefined) {
|
|
97
|
+
const age = user.age as number;
|
|
98
|
+
expect(age).toBeGreaterThanOrEqual(0);
|
|
99
|
+
expect(age).toBeLessThanOrEqual(150);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("generates valid enum values from string unions", () => {
|
|
104
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
105
|
+
seed: 42,
|
|
106
|
+
});
|
|
107
|
+
const user = factory.build();
|
|
108
|
+
expect(["admin", "user", "moderator"]).toContain(user.role);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("overrides specific fields", () => {
|
|
112
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
113
|
+
seed: 42,
|
|
114
|
+
});
|
|
115
|
+
const user = factory.build({
|
|
116
|
+
displayName: "Admin User",
|
|
117
|
+
role: "admin",
|
|
118
|
+
});
|
|
119
|
+
expect(user.displayName).toBe("Admin User");
|
|
120
|
+
expect(user.role).toBe("admin");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("buildMany produces correct count", () => {
|
|
124
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
125
|
+
seed: 42,
|
|
126
|
+
});
|
|
127
|
+
const users = factory.buildMany(5);
|
|
128
|
+
expect(users.length).toBe(5);
|
|
129
|
+
for (const user of users) {
|
|
130
|
+
expect(user.email).toBeDefined();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("buildMany applies overrides to all instances", () => {
|
|
135
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
136
|
+
seed: 42,
|
|
137
|
+
});
|
|
138
|
+
const users = factory.buildMany(3, { role: "admin" });
|
|
139
|
+
for (const user of users) {
|
|
140
|
+
expect(user.role).toBe("admin");
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("buildInvalid produces invalid email", () => {
|
|
145
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
146
|
+
seed: 42,
|
|
147
|
+
});
|
|
148
|
+
const invalid = factory.buildInvalid("email");
|
|
149
|
+
expect(invalid.email).toBe("not-an-email");
|
|
150
|
+
// Other fields should still be valid
|
|
151
|
+
expect(typeof invalid.displayName).toBe("string");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("buildInvalid produces invalid UUID", () => {
|
|
155
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
156
|
+
seed: 42,
|
|
157
|
+
});
|
|
158
|
+
const invalid = factory.buildInvalid("id");
|
|
159
|
+
expect(invalid.id).toBe("not-a-uuid");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("buildInvalid produces invalid enum value", () => {
|
|
163
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
164
|
+
seed: 42,
|
|
165
|
+
});
|
|
166
|
+
const invalid = factory.buildInvalid("role");
|
|
167
|
+
expect(["admin", "user", "moderator"]).not.toContain(invalid.role);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("is deterministic with the same seed", () => {
|
|
171
|
+
const factory1 = createFactory<Record<string, unknown>>(userMetadata, {
|
|
172
|
+
seed: 42,
|
|
173
|
+
});
|
|
174
|
+
const factory2 = createFactory<Record<string, unknown>>(userMetadata, {
|
|
175
|
+
seed: 42,
|
|
176
|
+
});
|
|
177
|
+
const user1 = factory1.build();
|
|
178
|
+
const user2 = factory2.build();
|
|
179
|
+
expect(user1).toEqual(user2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("produces different output with different seeds", () => {
|
|
183
|
+
const factory1 = createFactory<Record<string, unknown>>(userMetadata, {
|
|
184
|
+
seed: 42,
|
|
185
|
+
});
|
|
186
|
+
const factory2 = createFactory<Record<string, unknown>>(userMetadata, {
|
|
187
|
+
seed: 99,
|
|
188
|
+
});
|
|
189
|
+
const user1 = factory1.build();
|
|
190
|
+
const user2 = factory2.build();
|
|
191
|
+
expect(user1.email).not.toEqual(user2.email);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("generates date-time format values", () => {
|
|
195
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
196
|
+
seed: 42,
|
|
197
|
+
});
|
|
198
|
+
const user = factory.build();
|
|
199
|
+
const date = user.createdAt as string;
|
|
200
|
+
expect(date).toContain("T");
|
|
201
|
+
expect(date).toContain("Z");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("generates arrays for array types", () => {
|
|
205
|
+
const factory = createFactory<Record<string, unknown>>(userMetadata, {
|
|
206
|
+
seed: 42,
|
|
207
|
+
});
|
|
208
|
+
const user = factory.build();
|
|
209
|
+
if (user.tags !== undefined) {
|
|
210
|
+
expect(Array.isArray(user.tags)).toBe(true);
|
|
211
|
+
const tags = user.tags as string[];
|
|
212
|
+
for (const tag of tags) {
|
|
213
|
+
expect(typeof tag).toBe("string");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|