@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.
@@ -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
+ });