@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/src/index.ts ADDED
@@ -0,0 +1,284 @@
1
+ // @typokit/testing — Test Client, Integration Suite & Factories
2
+
3
+ import type { TypoKitApp } from "@typokit/core";
4
+ import type { RouteContract } from "@typokit/types";
5
+
6
+ export {
7
+ createIntegrationSuite,
8
+ registerSeed,
9
+ getSeed,
10
+ } from "./integration-suite.js";
11
+ export type {
12
+ IntegrationSuite,
13
+ IntegrationSuiteOptions,
14
+ InMemoryDatabase,
15
+ SeedFn,
16
+ } from "./integration-suite.js";
17
+
18
+ export { createFactory } from "./factory.js";
19
+ export type { Factory, FactoryOptions } from "./factory.js";
20
+
21
+ export {
22
+ toMatchSchema,
23
+ matchSchema,
24
+ registerSchemaValidators,
25
+ getSchemaValidator,
26
+ clearSchemaValidators,
27
+ } from "./schema-matcher.js";
28
+ export type { SchemaMatchResult, SchemaMatchers } from "./schema-matcher.js";
29
+
30
+ export {
31
+ generateContractTests,
32
+ detectTestRunner,
33
+ } from "./contract-generator.js";
34
+ export type {
35
+ ContractTestRoute,
36
+ ContractTestOptions,
37
+ ContractTestOutput,
38
+ TestRunner,
39
+ } from "./contract-generator.js";
40
+
41
+ // ─── Response Type ───────────────────────────────────────────
42
+
43
+ /** Response returned by test client methods */
44
+ export interface TestResponse<TBody = unknown> {
45
+ status: number;
46
+ body: TBody;
47
+ headers: Record<string, string>;
48
+ }
49
+
50
+ // ─── Request Options ─────────────────────────────────────────
51
+
52
+ /** Options for test client request methods */
53
+ export interface TestRequestOptions {
54
+ body?: unknown;
55
+ query?: Record<string, string | string[]>;
56
+ headers?: Record<string, string>;
57
+ }
58
+
59
+ /** Type-safe request options using a RouteContract */
60
+ export interface TypedRequestOptions<
61
+ TContract extends RouteContract<unknown, unknown, unknown, unknown>,
62
+ > {
63
+ body?: TContract["body"] extends void ? never : TContract["body"];
64
+ query?: TContract["query"] extends void ? never : TContract["query"];
65
+ headers?: Record<string, string>;
66
+ }
67
+
68
+ // ─── Test Client Interface ───────────────────────────────────
69
+
70
+ /** A test client for making typed HTTP requests against a TypoKit app */
71
+ export interface TestClient {
72
+ /** Send a GET request */
73
+ get<TResponse = unknown>(
74
+ path: string,
75
+ options?: TestRequestOptions,
76
+ ): Promise<TestResponse<TResponse>>;
77
+
78
+ /** Send a POST request */
79
+ post<TResponse = unknown>(
80
+ path: string,
81
+ options?: TestRequestOptions,
82
+ ): Promise<TestResponse<TResponse>>;
83
+
84
+ /** Send a PUT request */
85
+ put<TResponse = unknown>(
86
+ path: string,
87
+ options?: TestRequestOptions,
88
+ ): Promise<TestResponse<TResponse>>;
89
+
90
+ /** Send a PATCH request */
91
+ patch<TResponse = unknown>(
92
+ path: string,
93
+ options?: TestRequestOptions,
94
+ ): Promise<TestResponse<TResponse>>;
95
+
96
+ /** Send a DELETE request */
97
+ delete<TResponse = unknown>(
98
+ path: string,
99
+ options?: TestRequestOptions,
100
+ ): Promise<TestResponse<TResponse>>;
101
+
102
+ /** Send a contract-typed request */
103
+ request<TContract extends RouteContract<unknown, unknown, unknown, unknown>>(
104
+ method: string,
105
+ path: string,
106
+ options?: TypedRequestOptions<TContract>,
107
+ ): Promise<TestResponse<TContract["response"]>>;
108
+
109
+ /** Shut down the test server */
110
+ close(): Promise<void>;
111
+
112
+ /** The base URL the test server is listening on */
113
+ baseUrl: string;
114
+ }
115
+
116
+ // ─── Internal Helpers ────────────────────────────────────────
117
+
118
+ /** Build a URL with query parameters */
119
+ function buildUrl(
120
+ base: string,
121
+ path: string,
122
+ query?: Record<string, string | string[]>,
123
+ ): string {
124
+ let url = `${base}${path}`;
125
+ if (query && Object.keys(query).length > 0) {
126
+ const params = new URLSearchParams();
127
+ for (const [key, value] of Object.entries(query)) {
128
+ if (Array.isArray(value)) {
129
+ for (const v of value) {
130
+ params.append(key, v);
131
+ }
132
+ } else {
133
+ params.append(key, value);
134
+ }
135
+ }
136
+ url += `?${params.toString()}`;
137
+ }
138
+ return url;
139
+ }
140
+
141
+ /** Parse response headers into a flat Record */
142
+ function parseHeaders(headers: Headers): Record<string, string> {
143
+ const result: Record<string, string> = {};
144
+ headers.forEach((value, key) => {
145
+ result[key] = value;
146
+ });
147
+ return result;
148
+ }
149
+
150
+ /** Execute an HTTP request and return a TestResponse */
151
+ async function executeRequest<TResponse>(
152
+ baseUrl: string,
153
+ method: string,
154
+ path: string,
155
+ options: TestRequestOptions = {},
156
+ ): Promise<TestResponse<TResponse>> {
157
+ const url = buildUrl(baseUrl, path, options.query);
158
+
159
+ const headers: Record<string, string> = { ...options.headers };
160
+ let bodyStr: string | undefined;
161
+
162
+ if (options.body !== undefined) {
163
+ if (!headers["content-type"]) {
164
+ headers["content-type"] = "application/json";
165
+ }
166
+ bodyStr =
167
+ typeof options.body === "string"
168
+ ? options.body
169
+ : JSON.stringify(options.body);
170
+ }
171
+
172
+ const response = await fetch(url, {
173
+ method,
174
+ headers,
175
+ body: bodyStr,
176
+ });
177
+
178
+ const responseHeaders = parseHeaders(response.headers);
179
+
180
+ const contentType = response.headers.get("content-type") ?? "";
181
+ let body: TResponse;
182
+ if (contentType.includes("application/json")) {
183
+ body = (await response.json()) as TResponse;
184
+ } else {
185
+ body = (await response.text()) as unknown as TResponse;
186
+ }
187
+
188
+ return {
189
+ status: response.status,
190
+ body,
191
+ headers: responseHeaders,
192
+ };
193
+ }
194
+
195
+ // ─── createTestClient ────────────────────────────────────────
196
+
197
+ /**
198
+ * Create a test client for a TypoKit application.
199
+ *
200
+ * Starts the app on a random port and returns a typed HTTP client.
201
+ * Call `client.close()` when done to shut down the server.
202
+ *
203
+ * ```ts
204
+ * const client = await createTestClient(app);
205
+ * const res = await client.get<{ message: string }>("/hello");
206
+ * expect(res.status).toBe(200);
207
+ * await client.close();
208
+ * ```
209
+ */
210
+ export async function createTestClient(app: TypoKitApp): Promise<TestClient> {
211
+ // Start on port 0 for auto-assigned random port
212
+ await app.listen(0);
213
+
214
+ // Get the actual port from the underlying server
215
+ const nativeServer = app.getNativeServer() as {
216
+ address(): { port: number } | string | null;
217
+ };
218
+ const addr = nativeServer.address();
219
+ if (!addr || typeof addr === "string") {
220
+ throw new Error("Failed to determine server port");
221
+ }
222
+ const port = addr.port;
223
+ const baseUrl = `http://127.0.0.1:${port}`;
224
+
225
+ const client: TestClient = {
226
+ baseUrl,
227
+
228
+ get<TResponse = unknown>(
229
+ path: string,
230
+ options?: TestRequestOptions,
231
+ ): Promise<TestResponse<TResponse>> {
232
+ return executeRequest<TResponse>(baseUrl, "GET", path, options);
233
+ },
234
+
235
+ post<TResponse = unknown>(
236
+ path: string,
237
+ options?: TestRequestOptions,
238
+ ): Promise<TestResponse<TResponse>> {
239
+ return executeRequest<TResponse>(baseUrl, "POST", path, options);
240
+ },
241
+
242
+ put<TResponse = unknown>(
243
+ path: string,
244
+ options?: TestRequestOptions,
245
+ ): Promise<TestResponse<TResponse>> {
246
+ return executeRequest<TResponse>(baseUrl, "PUT", path, options);
247
+ },
248
+
249
+ patch<TResponse = unknown>(
250
+ path: string,
251
+ options?: TestRequestOptions,
252
+ ): Promise<TestResponse<TResponse>> {
253
+ return executeRequest<TResponse>(baseUrl, "PATCH", path, options);
254
+ },
255
+
256
+ delete<TResponse = unknown>(
257
+ path: string,
258
+ options?: TestRequestOptions,
259
+ ): Promise<TestResponse<TResponse>> {
260
+ return executeRequest<TResponse>(baseUrl, "DELETE", path, options);
261
+ },
262
+
263
+ request<
264
+ TContract extends RouteContract<unknown, unknown, unknown, unknown>,
265
+ >(
266
+ method: string,
267
+ path: string,
268
+ options?: TypedRequestOptions<TContract>,
269
+ ): Promise<TestResponse<TContract["response"]>> {
270
+ return executeRequest(
271
+ baseUrl,
272
+ method,
273
+ path,
274
+ options as TestRequestOptions,
275
+ );
276
+ },
277
+
278
+ async close(): Promise<void> {
279
+ await app.close();
280
+ },
281
+ };
282
+
283
+ return client;
284
+ }
@@ -0,0 +1,336 @@
1
+ // @typokit/testing — Integration Suite Tests
2
+
3
+ import { describe, it, expect } from "@rstest/core";
4
+ import type {
5
+ CompiledRoute,
6
+ CompiledRouteTable,
7
+ HandlerMap,
8
+ MiddlewareChain,
9
+ TypoKitRequest,
10
+ TypoKitResponse,
11
+ } from "@typokit/types";
12
+ import { createApp } from "@typokit/core";
13
+ import { nativeServer } from "@typokit/server-native";
14
+ import { createIntegrationSuite, registerSeed } from "./integration-suite.js";
15
+ import type { InMemoryDatabase } from "./integration-suite.js";
16
+
17
+ // ─── Test Helpers ────────────────────────────────────────────
18
+
19
+ function makeRouteTable(): CompiledRouteTable {
20
+ const root: CompiledRoute = {
21
+ segment: "",
22
+ handlers: {
23
+ GET: { ref: "root#index", middleware: [] },
24
+ },
25
+ children: {
26
+ items: {
27
+ segment: "items",
28
+ handlers: {
29
+ GET: { ref: "items#list", middleware: [] },
30
+ POST: { ref: "items#create", middleware: [] },
31
+ },
32
+ },
33
+ },
34
+ };
35
+ return root;
36
+ }
37
+
38
+ function makeHandlerMap(): HandlerMap {
39
+ return {
40
+ "root#index": async (): Promise<TypoKitResponse> => ({
41
+ status: 200,
42
+ headers: { "content-type": "application/json" },
43
+ body: { message: "Integration Suite Test" },
44
+ }),
45
+ "items#list": async (_req: TypoKitRequest): Promise<TypoKitResponse> => ({
46
+ status: 200,
47
+ headers: { "content-type": "application/json" },
48
+ body: { items: [] },
49
+ }),
50
+ "items#create": async (req: TypoKitRequest): Promise<TypoKitResponse> => ({
51
+ status: 201,
52
+ headers: { "content-type": "application/json" },
53
+ body: req.body,
54
+ }),
55
+ };
56
+ }
57
+
58
+ function createTestApp() {
59
+ const adapter = nativeServer();
60
+ const routeTable = makeRouteTable();
61
+ const handlerMap = makeHandlerMap();
62
+ const middlewareChain: MiddlewareChain = { entries: [] };
63
+ adapter.registerRoutes(routeTable, handlerMap, middlewareChain);
64
+ return createApp({ server: adapter, routes: [] });
65
+ }
66
+
67
+ // ─── Register test seeds ─────────────────────────────────────
68
+
69
+ registerSeed("test-items", (db: InMemoryDatabase) => {
70
+ db.insert("items", { id: "1", name: "Alpha" });
71
+ db.insert("items", { id: "2", name: "Beta" });
72
+ db.insert("items", { id: "3", name: "Gamma" });
73
+ });
74
+
75
+ registerSeed("test-users", (db: InMemoryDatabase) => {
76
+ db.insert("users", { id: "u1", name: "Alice", role: "admin" });
77
+ db.insert("users", { id: "u2", name: "Bob", role: "user" });
78
+ });
79
+
80
+ // ─── Tests ───────────────────────────────────────────────────
81
+
82
+ describe("createIntegrationSuite", () => {
83
+ it("should create a suite and provide a client after setup", async () => {
84
+ const app = createTestApp();
85
+ const suite = createIntegrationSuite(app);
86
+
87
+ await suite.setup();
88
+ try {
89
+ expect(suite.client).toBeDefined();
90
+ expect(suite.client.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
91
+ } finally {
92
+ await suite.teardown();
93
+ }
94
+ });
95
+
96
+ it("should throw when accessing client before setup", () => {
97
+ const app = createTestApp();
98
+ const suite = createIntegrationSuite(app);
99
+
100
+ expect(() => suite.client).toThrow(
101
+ "Integration suite not set up. Call suite.setup() first.",
102
+ );
103
+ });
104
+
105
+ it("should make requests through the suite client", async () => {
106
+ const app = createTestApp();
107
+ const suite = createIntegrationSuite(app);
108
+
109
+ await suite.setup();
110
+ try {
111
+ const res = await suite.client.get<{ message: string }>("/");
112
+ expect(res.status).toBe(200);
113
+ expect(res.body.message).toBe("Integration Suite Test");
114
+ } finally {
115
+ await suite.teardown();
116
+ }
117
+ });
118
+
119
+ it("should have null db when database option is false", async () => {
120
+ const app = createTestApp();
121
+ const suite = createIntegrationSuite(app, { database: false });
122
+
123
+ await suite.setup();
124
+ try {
125
+ expect(suite.db).toBeNull();
126
+ } finally {
127
+ await suite.teardown();
128
+ }
129
+ });
130
+
131
+ it("should provide an in-memory database when database option is true", async () => {
132
+ const app = createTestApp();
133
+ const suite = createIntegrationSuite(app, { database: true });
134
+
135
+ await suite.setup();
136
+ try {
137
+ expect(suite.db).not.toBeNull();
138
+ expect(suite.db!.tables()).toHaveLength(0);
139
+ } finally {
140
+ await suite.teardown();
141
+ }
142
+ });
143
+
144
+ it("should seed the database with registered fixtures", async () => {
145
+ const app = createTestApp();
146
+ const suite = createIntegrationSuite(app, {
147
+ database: true,
148
+ seed: "test-items",
149
+ });
150
+
151
+ await suite.setup();
152
+ try {
153
+ expect(suite.db).not.toBeNull();
154
+ const items = suite.db!.findAll("items");
155
+ expect(items).toHaveLength(3);
156
+ expect(items[0].name).toBe("Alpha");
157
+ expect(items[2].name).toBe("Gamma");
158
+ } finally {
159
+ await suite.teardown();
160
+ }
161
+ });
162
+
163
+ it("should seed with a different fixture", async () => {
164
+ const app = createTestApp();
165
+ const suite = createIntegrationSuite(app, {
166
+ database: true,
167
+ seed: "test-users",
168
+ });
169
+
170
+ await suite.setup();
171
+ try {
172
+ const users = suite.db!.findAll("users");
173
+ expect(users).toHaveLength(2);
174
+ expect(users[0].name).toBe("Alice");
175
+ expect(users[0].role).toBe("admin");
176
+ } finally {
177
+ await suite.teardown();
178
+ }
179
+ });
180
+
181
+ it("should throw for unknown seed fixture", async () => {
182
+ const app = createTestApp();
183
+ const suite = createIntegrationSuite(app, {
184
+ database: true,
185
+ seed: "nonexistent-seed",
186
+ });
187
+
188
+ await expect(suite.setup()).rejects.toThrow(
189
+ 'Unknown seed fixture: "nonexistent-seed"',
190
+ );
191
+ });
192
+
193
+ it("should support findById on in-memory database", async () => {
194
+ const app = createTestApp();
195
+ const suite = createIntegrationSuite(app, {
196
+ database: true,
197
+ seed: "test-items",
198
+ });
199
+
200
+ await suite.setup();
201
+ try {
202
+ const item = suite.db!.findById("items", "2");
203
+ expect(item).toBeDefined();
204
+ expect(item!.name).toBe("Beta");
205
+
206
+ const missing = suite.db!.findById("items", "999");
207
+ expect(missing).toBeUndefined();
208
+ } finally {
209
+ await suite.teardown();
210
+ }
211
+ });
212
+
213
+ it("should support clearTable on in-memory database", async () => {
214
+ const app = createTestApp();
215
+ const suite = createIntegrationSuite(app, {
216
+ database: true,
217
+ seed: "test-items",
218
+ });
219
+
220
+ await suite.setup();
221
+ try {
222
+ expect(suite.db!.findAll("items")).toHaveLength(3);
223
+ suite.db!.clearTable("items");
224
+ expect(suite.db!.findAll("items")).toHaveLength(0);
225
+ } finally {
226
+ await suite.teardown();
227
+ }
228
+ });
229
+
230
+ it("should provide isolated databases per suite instance", async () => {
231
+ const app1 = createTestApp();
232
+ const suite1 = createIntegrationSuite(app1, {
233
+ database: true,
234
+ seed: "test-items",
235
+ });
236
+
237
+ const app2 = createTestApp();
238
+ const suite2 = createIntegrationSuite(app2, {
239
+ database: true,
240
+ seed: "test-items",
241
+ });
242
+
243
+ await suite1.setup();
244
+ await suite2.setup();
245
+ try {
246
+ // Modify suite1's database
247
+ suite1.db!.insert("items", { id: "4", name: "Delta" });
248
+ suite1.db!.clearTable("items");
249
+
250
+ // suite2's database should be untouched
251
+ expect(suite2.db!.findAll("items")).toHaveLength(3);
252
+ expect(suite1.db!.findAll("items")).toHaveLength(0);
253
+ } finally {
254
+ await suite1.teardown();
255
+ await suite2.teardown();
256
+ }
257
+ });
258
+
259
+ it("should clean up database on teardown", async () => {
260
+ const app = createTestApp();
261
+ const suite = createIntegrationSuite(app, {
262
+ database: true,
263
+ seed: "test-items",
264
+ });
265
+
266
+ await suite.setup();
267
+ expect(suite.db).not.toBeNull();
268
+ await suite.teardown();
269
+ expect(suite.db).toBeNull();
270
+ });
271
+
272
+ it("should have no shared mutable state between sequential setups", async () => {
273
+ const app1 = createTestApp();
274
+ const suite = createIntegrationSuite(app1, {
275
+ database: true,
276
+ seed: "test-items",
277
+ });
278
+
279
+ // First setup + modification
280
+ await suite.setup();
281
+ suite.db!.insert("items", { id: "extra", name: "Extra" });
282
+ expect(suite.db!.findAll("items")).toHaveLength(4);
283
+ await suite.teardown();
284
+
285
+ // Second setup — fresh state
286
+ const app2 = createTestApp();
287
+ const suite2 = createIntegrationSuite(app2, {
288
+ database: true,
289
+ seed: "test-items",
290
+ });
291
+ await suite2.setup();
292
+ try {
293
+ expect(suite2.db!.findAll("items")).toHaveLength(3);
294
+ } finally {
295
+ await suite2.teardown();
296
+ }
297
+ });
298
+
299
+ it("should support insert and query on in-memory database", async () => {
300
+ const app = createTestApp();
301
+ const suite = createIntegrationSuite(app, { database: true });
302
+
303
+ await suite.setup();
304
+ try {
305
+ suite.db!.insert("orders", { id: "o1", total: 100 });
306
+ suite.db!.insert("orders", { id: "o2", total: 200 });
307
+
308
+ const orders = suite.db!.findAll("orders");
309
+ expect(orders).toHaveLength(2);
310
+ expect(orders[0].total).toBe(100);
311
+
312
+ expect(suite.db!.tables()).toContain("orders");
313
+ } finally {
314
+ await suite.teardown();
315
+ }
316
+ });
317
+
318
+ it("should return copies from findAll to prevent mutation", async () => {
319
+ const app = createTestApp();
320
+ const suite = createIntegrationSuite(app, {
321
+ database: true,
322
+ seed: "test-items",
323
+ });
324
+
325
+ await suite.setup();
326
+ try {
327
+ const items = suite.db!.findAll("items");
328
+ // Mutating returned record should not affect the database
329
+ items[0].name = "Mutated";
330
+ const fresh = suite.db!.findAll("items");
331
+ expect(fresh[0].name).toBe("Alpha");
332
+ } finally {
333
+ await suite.teardown();
334
+ }
335
+ });
336
+ });