@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,191 @@
|
|
|
1
|
+
// @typokit/testing — Integration Test Suite
|
|
2
|
+
|
|
3
|
+
import type { TypoKitApp } from "@typokit/core";
|
|
4
|
+
import type { TestClient } from "./index.js";
|
|
5
|
+
import { createTestClient } from "./index.js";
|
|
6
|
+
|
|
7
|
+
// ─── In-Memory Database ─────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/** A simple in-memory database for integration testing */
|
|
10
|
+
export interface InMemoryDatabase {
|
|
11
|
+
/** Insert a record into a table */
|
|
12
|
+
insert(table: string, record: Record<string, unknown>): void;
|
|
13
|
+
|
|
14
|
+
/** Find all records in a table */
|
|
15
|
+
findAll(table: string): Record<string, unknown>[];
|
|
16
|
+
|
|
17
|
+
/** Find a record by id field */
|
|
18
|
+
findById(table: string, id: string): Record<string, unknown> | undefined;
|
|
19
|
+
|
|
20
|
+
/** Delete all records from a table */
|
|
21
|
+
clearTable(table: string): void;
|
|
22
|
+
|
|
23
|
+
/** Delete all records from all tables */
|
|
24
|
+
clear(): void;
|
|
25
|
+
|
|
26
|
+
/** Get the list of table names */
|
|
27
|
+
tables(): string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createInMemoryDatabase(): InMemoryDatabase {
|
|
31
|
+
const store = new Map<string, Record<string, unknown>[]>();
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
insert(table: string, record: Record<string, unknown>): void {
|
|
35
|
+
if (!store.has(table)) {
|
|
36
|
+
store.set(table, []);
|
|
37
|
+
}
|
|
38
|
+
// Clone the record to prevent shared references
|
|
39
|
+
store.get(table)!.push({ ...record });
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
findAll(table: string): Record<string, unknown>[] {
|
|
43
|
+
return (store.get(table) ?? []).map((r) => ({ ...r }));
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
findById(table: string, id: string): Record<string, unknown> | undefined {
|
|
47
|
+
const records = store.get(table);
|
|
48
|
+
if (!records) return undefined;
|
|
49
|
+
const found = records.find((r) => r["id"] === id);
|
|
50
|
+
return found ? { ...found } : undefined;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
clearTable(table: string): void {
|
|
54
|
+
store.delete(table);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
clear(): void {
|
|
58
|
+
store.clear();
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
tables(): string[] {
|
|
62
|
+
return [...store.keys()];
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Seed Data ──────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** A seed function populates an in-memory database with test data */
|
|
70
|
+
export type SeedFn = (db: InMemoryDatabase) => void;
|
|
71
|
+
|
|
72
|
+
const seedRegistry = new Map<string, SeedFn>();
|
|
73
|
+
|
|
74
|
+
/** Register a named seed data fixture */
|
|
75
|
+
export function registerSeed(name: string, seed: SeedFn): void {
|
|
76
|
+
seedRegistry.set(name, seed);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Get a registered seed function by name */
|
|
80
|
+
export function getSeed(name: string): SeedFn | undefined {
|
|
81
|
+
return seedRegistry.get(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Integration Suite ──────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** Options for creating an integration test suite */
|
|
87
|
+
export interface IntegrationSuiteOptions {
|
|
88
|
+
/** Whether to spin up an in-memory database (default: false) */
|
|
89
|
+
database?: boolean;
|
|
90
|
+
|
|
91
|
+
/** Name of a registered seed data fixture to apply */
|
|
92
|
+
seed?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** An integration test suite with managed server and database lifecycle */
|
|
96
|
+
export interface IntegrationSuite {
|
|
97
|
+
/** Start the server and seed the database */
|
|
98
|
+
setup(): Promise<void>;
|
|
99
|
+
|
|
100
|
+
/** Stop the server and clean up */
|
|
101
|
+
teardown(): Promise<void>;
|
|
102
|
+
|
|
103
|
+
/** The typed test client (available after setup) */
|
|
104
|
+
readonly client: TestClient;
|
|
105
|
+
|
|
106
|
+
/** The in-memory database (null if database option is false) */
|
|
107
|
+
readonly db: InMemoryDatabase | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create an integration test suite for a TypoKit application.
|
|
112
|
+
*
|
|
113
|
+
* Sets up an isolated server and optionally an in-memory database
|
|
114
|
+
* with seeded test data. Each call creates a fully independent
|
|
115
|
+
* instance with no shared mutable state.
|
|
116
|
+
*
|
|
117
|
+
* ```ts
|
|
118
|
+
* const suite = createIntegrationSuite(app, {
|
|
119
|
+
* database: true,
|
|
120
|
+
* seed: "users",
|
|
121
|
+
* });
|
|
122
|
+
*
|
|
123
|
+
* // In beforeEach / setup
|
|
124
|
+
* await suite.setup();
|
|
125
|
+
*
|
|
126
|
+
* // In tests
|
|
127
|
+
* const res = await suite.client.get("/users");
|
|
128
|
+
*
|
|
129
|
+
* // In afterEach / teardown
|
|
130
|
+
* await suite.teardown();
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export function createIntegrationSuite(
|
|
134
|
+
app: TypoKitApp,
|
|
135
|
+
options: IntegrationSuiteOptions = {},
|
|
136
|
+
): IntegrationSuite {
|
|
137
|
+
let client: TestClient | null = null;
|
|
138
|
+
let db: InMemoryDatabase | null = null;
|
|
139
|
+
|
|
140
|
+
const suite: IntegrationSuite = {
|
|
141
|
+
async setup(): Promise<void> {
|
|
142
|
+
// Create a fresh in-memory database if requested
|
|
143
|
+
if (options.database) {
|
|
144
|
+
db = createInMemoryDatabase();
|
|
145
|
+
|
|
146
|
+
// Apply seed data if specified
|
|
147
|
+
if (options.seed) {
|
|
148
|
+
const seedFn = getSeed(options.seed);
|
|
149
|
+
if (!seedFn) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Unknown seed fixture: "${options.seed}". Register it with registerSeed() first.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
seedFn(db);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Start the server and create a test client
|
|
159
|
+
client = await createTestClient(app);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async teardown(): Promise<void> {
|
|
163
|
+
// Close the test client / server
|
|
164
|
+
if (client) {
|
|
165
|
+
await client.close();
|
|
166
|
+
client = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Clear the database
|
|
170
|
+
if (db) {
|
|
171
|
+
db.clear();
|
|
172
|
+
db = null;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
get client(): TestClient {
|
|
177
|
+
if (!client) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
"Integration suite not set up. Call suite.setup() first.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return client;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
get db(): InMemoryDatabase | null {
|
|
186
|
+
return db;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return suite;
|
|
191
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "@rstest/core";
|
|
2
|
+
import {
|
|
3
|
+
toMatchSchema,
|
|
4
|
+
registerSchemaValidators,
|
|
5
|
+
clearSchemaValidators,
|
|
6
|
+
matchSchema,
|
|
7
|
+
getSchemaValidator,
|
|
8
|
+
} from "./schema-matcher.js";
|
|
9
|
+
import type { ValidatorFn, ValidationFieldError } from "@typokit/types";
|
|
10
|
+
|
|
11
|
+
// ─── Test Helpers ─────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Creates a validator that passes for objects with required PublicUser fields */
|
|
14
|
+
function createPublicUserValidator(): ValidatorFn {
|
|
15
|
+
return (input: unknown) => {
|
|
16
|
+
if (typeof input !== "object" || input === null) {
|
|
17
|
+
return {
|
|
18
|
+
success: false,
|
|
19
|
+
errors: [
|
|
20
|
+
{ path: "$", expected: "object", actual: typeof input },
|
|
21
|
+
] as ValidationFieldError[],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const obj = input as Record<string, unknown>;
|
|
26
|
+
const errors: ValidationFieldError[] = [];
|
|
27
|
+
|
|
28
|
+
if (typeof obj["id"] !== "string") {
|
|
29
|
+
errors.push({ path: "id", expected: "string", actual: obj["id"] });
|
|
30
|
+
}
|
|
31
|
+
if (typeof obj["name"] !== "string") {
|
|
32
|
+
errors.push({ path: "name", expected: "string", actual: obj["name"] });
|
|
33
|
+
}
|
|
34
|
+
if (typeof obj["email"] !== "string") {
|
|
35
|
+
errors.push({ path: "email", expected: "string", actual: obj["email"] });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
success: errors.length === 0,
|
|
40
|
+
data: errors.length === 0 ? input : undefined,
|
|
41
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Creates a simple pass/fail validator */
|
|
47
|
+
function createSimpleValidator(
|
|
48
|
+
fieldName: string,
|
|
49
|
+
fieldType: string,
|
|
50
|
+
): ValidatorFn {
|
|
51
|
+
return (input: unknown) => {
|
|
52
|
+
if (typeof input !== "object" || input === null) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
errors: [{ path: "$", expected: "object", actual: typeof input }],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const obj = input as Record<string, unknown>;
|
|
59
|
+
if (typeof obj[fieldName] !== fieldType) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
errors: [
|
|
63
|
+
{ path: fieldName, expected: fieldType, actual: obj[fieldName] },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { success: true, data: input };
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Tests ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("schema-matcher", () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
clearSchemaValidators();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("registerSchemaValidators / getSchemaValidator", () => {
|
|
79
|
+
it("should register and retrieve validators", () => {
|
|
80
|
+
const validator = createPublicUserValidator();
|
|
81
|
+
registerSchemaValidators({ PublicUser: validator });
|
|
82
|
+
|
|
83
|
+
const retrieved = getSchemaValidator("PublicUser");
|
|
84
|
+
expect(retrieved).toBe(validator);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should throw when schema not registered", () => {
|
|
88
|
+
expect(() => getSchemaValidator("Unknown")).toThrow(
|
|
89
|
+
'Schema "Unknown" not registered',
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should list available schemas in error message", () => {
|
|
94
|
+
registerSchemaValidators({
|
|
95
|
+
PublicUser: createPublicUserValidator(),
|
|
96
|
+
Post: createSimpleValidator("title", "string"),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
getSchemaValidator("Missing");
|
|
101
|
+
expect(true).toBe(false); // should not reach
|
|
102
|
+
} catch (err: unknown) {
|
|
103
|
+
const msg = (err as Error).message;
|
|
104
|
+
expect(msg).toContain("PublicUser");
|
|
105
|
+
expect(msg).toContain("Post");
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should merge validators when registering multiple times", () => {
|
|
110
|
+
registerSchemaValidators({ A: createSimpleValidator("a", "string") });
|
|
111
|
+
registerSchemaValidators({ B: createSimpleValidator("b", "number") });
|
|
112
|
+
|
|
113
|
+
expect(() => getSchemaValidator("A")).not.toThrow();
|
|
114
|
+
expect(() => getSchemaValidator("B")).not.toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("clearSchemaValidators", () => {
|
|
119
|
+
it("should remove all registered validators", () => {
|
|
120
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
121
|
+
clearSchemaValidators();
|
|
122
|
+
|
|
123
|
+
expect(() => getSchemaValidator("PublicUser")).toThrow();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("matchSchema (core logic)", () => {
|
|
128
|
+
it("should pass for a valid PublicUser", () => {
|
|
129
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
130
|
+
|
|
131
|
+
const validUser = { id: "u1", name: "Alice", email: "alice@example.com" };
|
|
132
|
+
const result = matchSchema(validUser, "PublicUser");
|
|
133
|
+
|
|
134
|
+
expect(result.pass).toBe(true);
|
|
135
|
+
expect(result.message).toContain("NOT to match");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should fail for an invalid PublicUser (missing fields)", () => {
|
|
139
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
140
|
+
|
|
141
|
+
const invalidUser = { id: 123, name: "Alice" }; // id is number, email missing
|
|
142
|
+
const result = matchSchema(invalidUser, "PublicUser");
|
|
143
|
+
|
|
144
|
+
expect(result.pass).toBe(false);
|
|
145
|
+
expect(result.message).toContain("validation failed");
|
|
146
|
+
expect(result.message).toContain("id");
|
|
147
|
+
expect(result.message).toContain("email");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should fail for a non-object value", () => {
|
|
151
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
152
|
+
|
|
153
|
+
const result = matchSchema("not an object", "PublicUser");
|
|
154
|
+
expect(result.pass).toBe(false);
|
|
155
|
+
expect(result.message).toContain("validation failed");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should include field-level error details", () => {
|
|
159
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
160
|
+
|
|
161
|
+
const result = matchSchema(
|
|
162
|
+
{ id: 42, name: true, email: null },
|
|
163
|
+
"PublicUser",
|
|
164
|
+
);
|
|
165
|
+
expect(result.pass).toBe(false);
|
|
166
|
+
expect(result.message).toContain("id");
|
|
167
|
+
expect(result.message).toContain("expected string");
|
|
168
|
+
expect(result.message).toContain("name");
|
|
169
|
+
expect(result.message).toContain("email");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should show schema name in pass message", () => {
|
|
173
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
174
|
+
|
|
175
|
+
const result = matchSchema(
|
|
176
|
+
{ id: "1", name: "A", email: "a@b.com" },
|
|
177
|
+
"PublicUser",
|
|
178
|
+
);
|
|
179
|
+
expect(result.message).toContain('"PublicUser"');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("should show schema name in fail message", () => {
|
|
183
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
184
|
+
|
|
185
|
+
const result = matchSchema({}, "PublicUser");
|
|
186
|
+
expect(result.message).toContain('"PublicUser"');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("toMatchSchema (framework matcher)", () => {
|
|
191
|
+
it("should return pass: true for valid data", () => {
|
|
192
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
193
|
+
|
|
194
|
+
const result = toMatchSchema.call(
|
|
195
|
+
{},
|
|
196
|
+
{ id: "u1", name: "Alice", email: "alice@example.com" },
|
|
197
|
+
"PublicUser",
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(result.pass).toBe(true);
|
|
201
|
+
expect(typeof result.message).toBe("function");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should return pass: false for invalid data", () => {
|
|
205
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
206
|
+
|
|
207
|
+
const result = toMatchSchema.call({}, { wrong: "data" }, "PublicUser");
|
|
208
|
+
|
|
209
|
+
expect(result.pass).toBe(false);
|
|
210
|
+
expect(result.message()).toContain("validation failed");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should provide message as a function (Jest/Vitest API)", () => {
|
|
214
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
215
|
+
|
|
216
|
+
const result = toMatchSchema.call(
|
|
217
|
+
{},
|
|
218
|
+
{ id: "1", name: "A", email: "a@b" },
|
|
219
|
+
"PublicUser",
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Jest/Vitest expect message to be a function
|
|
223
|
+
expect(typeof result.message).toBe("function");
|
|
224
|
+
expect(typeof result.message()).toBe("string");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should work with .not context (isNot)", () => {
|
|
228
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
229
|
+
|
|
230
|
+
// When .not is used, pass: true means the assertion fails
|
|
231
|
+
const result = toMatchSchema.call(
|
|
232
|
+
{ isNot: true },
|
|
233
|
+
{ id: "1", name: "A", email: "a@b.com" },
|
|
234
|
+
"PublicUser",
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// pass is true (value matches), so .not would make it fail
|
|
238
|
+
expect(result.pass).toBe(true);
|
|
239
|
+
expect(result.message()).toContain("NOT to match");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("should work without this context", () => {
|
|
243
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
244
|
+
|
|
245
|
+
const result = toMatchSchema(
|
|
246
|
+
{ id: "1", name: "A", email: "a@b.com" },
|
|
247
|
+
"PublicUser",
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
expect(result.pass).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("expect.extend integration", () => {
|
|
255
|
+
it("should work with expect.extend for passing assertion", () => {
|
|
256
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
257
|
+
|
|
258
|
+
// Simulate how expect.extend works in all three frameworks
|
|
259
|
+
expect.extend({ toMatchSchema });
|
|
260
|
+
|
|
261
|
+
const validUser = { id: "u1", name: "Alice", email: "alice@example.com" };
|
|
262
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
263
|
+
(expect(validUser) as any).toMatchSchema("PublicUser");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should work with expect.extend for failing assertion", () => {
|
|
267
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
268
|
+
|
|
269
|
+
expect.extend({ toMatchSchema });
|
|
270
|
+
|
|
271
|
+
const invalidUser = { wrong: "data" };
|
|
272
|
+
try {
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
274
|
+
(expect(invalidUser) as any).toMatchSchema("PublicUser");
|
|
275
|
+
// Should not reach here
|
|
276
|
+
expect(true).toBe(false);
|
|
277
|
+
} catch (err: unknown) {
|
|
278
|
+
const msg = (err as Error).message;
|
|
279
|
+
expect(msg).toContain("validation failed");
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("should work with .not for negated assertions", () => {
|
|
284
|
+
registerSchemaValidators({ PublicUser: createPublicUserValidator() });
|
|
285
|
+
|
|
286
|
+
expect.extend({ toMatchSchema });
|
|
287
|
+
|
|
288
|
+
const invalidUser = { wrong: "data" };
|
|
289
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
290
|
+
(expect(invalidUser) as any).not.toMatchSchema("PublicUser");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @typokit/testing — toMatchSchema custom matcher
|
|
2
|
+
// Adapters for Jest, Vitest, and Rstest
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ValidatorFn,
|
|
6
|
+
ValidatorMap,
|
|
7
|
+
ValidationFieldError,
|
|
8
|
+
} from "@typokit/types";
|
|
9
|
+
|
|
10
|
+
// ─── Schema Registry ─────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Global registry of compiled validators keyed by schema name */
|
|
13
|
+
let validatorRegistry: ValidatorMap = {};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register compiled validators for use with toMatchSchema().
|
|
17
|
+
* Call this in your test setup with validators from your build output.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { registerSchemaValidators } from "@typokit/testing";
|
|
21
|
+
* registerSchemaValidators({
|
|
22
|
+
* PublicUser: (input) => ({ success: true, data: input }),
|
|
23
|
+
* CreateUserInput: myTypiaValidator,
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function registerSchemaValidators(validators: ValidatorMap): void {
|
|
28
|
+
validatorRegistry = { ...validatorRegistry, ...validators };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get a validator by schema name. Throws if not registered.
|
|
33
|
+
*/
|
|
34
|
+
export function getSchemaValidator(schemaName: string): ValidatorFn {
|
|
35
|
+
const validator = validatorRegistry[schemaName];
|
|
36
|
+
if (!validator) {
|
|
37
|
+
const available = Object.keys(validatorRegistry);
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Schema "${schemaName}" not registered. ` +
|
|
40
|
+
`Available schemas: ${available.length > 0 ? available.join(", ") : "(none)"}. ` +
|
|
41
|
+
`Call registerSchemaValidators() in your test setup.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return validator;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Clear all registered validators (for test isolation) */
|
|
48
|
+
export function clearSchemaValidators(): void {
|
|
49
|
+
validatorRegistry = {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Matcher Result ──────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Result of a schema match operation */
|
|
55
|
+
export interface SchemaMatchResult {
|
|
56
|
+
pass: boolean;
|
|
57
|
+
message: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Core Matcher Logic ──────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Format validation errors into a human-readable message.
|
|
64
|
+
*/
|
|
65
|
+
function formatErrors(errors: ValidationFieldError[]): string {
|
|
66
|
+
return errors
|
|
67
|
+
.map(
|
|
68
|
+
(e) =>
|
|
69
|
+
` • ${e.path}: expected ${e.expected}, received ${JSON.stringify(e.actual)}`,
|
|
70
|
+
)
|
|
71
|
+
.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Core schema matching logic shared by all framework adapters.
|
|
76
|
+
*/
|
|
77
|
+
export function matchSchema(
|
|
78
|
+
received: unknown,
|
|
79
|
+
schemaName: string,
|
|
80
|
+
): SchemaMatchResult {
|
|
81
|
+
const validator = getSchemaValidator(schemaName);
|
|
82
|
+
const result = validator(received);
|
|
83
|
+
|
|
84
|
+
if (result.success) {
|
|
85
|
+
return {
|
|
86
|
+
pass: true,
|
|
87
|
+
message: `Expected value NOT to match schema "${schemaName}", but it did.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const errorDetails =
|
|
92
|
+
result.errors && result.errors.length > 0
|
|
93
|
+
? `\nField errors:\n${formatErrors(result.errors)}`
|
|
94
|
+
: "";
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
pass: false,
|
|
98
|
+
message: `Expected value to match schema "${schemaName}", but validation failed.${errorDetails}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Jest / Vitest / Rstest Adapter ──────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Custom matcher for Jest, Vitest, and Rstest expect() chains.
|
|
106
|
+
*
|
|
107
|
+
* All three frameworks share compatible matcher APIs:
|
|
108
|
+
* - `this.isNot` indicates `.not.toMatchSchema()` usage
|
|
109
|
+
* - Return `{ pass, message() }` object
|
|
110
|
+
*
|
|
111
|
+
* Usage:
|
|
112
|
+
* ```ts
|
|
113
|
+
* import { toMatchSchema, registerSchemaValidators } from "@typokit/testing";
|
|
114
|
+
*
|
|
115
|
+
* // For Jest:
|
|
116
|
+
* expect.extend({ toMatchSchema });
|
|
117
|
+
*
|
|
118
|
+
* // For Vitest:
|
|
119
|
+
* expect.extend({ toMatchSchema });
|
|
120
|
+
*
|
|
121
|
+
* // For Rstest:
|
|
122
|
+
* expect.extend({ toMatchSchema });
|
|
123
|
+
*
|
|
124
|
+
* // Then in tests:
|
|
125
|
+
* expect(responseBody).toMatchSchema("PublicUser");
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
export function toMatchSchema(
|
|
129
|
+
this: { isNot?: boolean } | void,
|
|
130
|
+
received: unknown,
|
|
131
|
+
schemaName: string,
|
|
132
|
+
): { pass: boolean; message: () => string } {
|
|
133
|
+
const result = matchSchema(received, schemaName);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
pass: result.pass,
|
|
137
|
+
message: () => result.message,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Type Augmentations ──────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Type declaration for extending expect() in Jest, Vitest, and Rstest.
|
|
145
|
+
*
|
|
146
|
+
* Users should add to their test setup:
|
|
147
|
+
* ```ts
|
|
148
|
+
* declare module "expect" {
|
|
149
|
+
* interface AsymmetricMatchers {
|
|
150
|
+
* toMatchSchema(schemaName: string): void;
|
|
151
|
+
* }
|
|
152
|
+
* interface Matchers<R> {
|
|
153
|
+
* toMatchSchema(schemaName: string): R;
|
|
154
|
+
* }
|
|
155
|
+
* }
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export interface SchemaMatchers<R = unknown> {
|
|
159
|
+
toMatchSchema(schemaName: string): R;
|
|
160
|
+
}
|