/** * OpenAPI Generator Tests */ import { describe, test, expect } from "bun:test"; import { z } from "zod"; import { generateOpenAPIDocument, hashOpenAPIJSON, hoistSharedSchemas, openAPIToJSON, openAPIToYAML, readOpenAPIArtifacts, writeOpenAPIArtifacts, zodToOpenAPISchema, } from "./generator"; import type { OpenAPIDocument } from "./generator"; import type { RoutesManifest } from "../spec/schema"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; async function linkWorkspaceNodeModules(rootDir: string): Promise { const workspaceNodeModules = path.join(process.cwd(), "node_modules"); const fixtureNodeModules = path.join(rootDir, "node_modules"); try { await fs.access(workspaceNodeModules); } catch { return; } try { await fs.symlink( workspaceNodeModules, fixtureNodeModules, process.platform === "win32" ? "junction" : "dir" ); } catch (error) { try { await fs.access(fixtureNodeModules); } catch { throw error; } } } describe("zodToOpenAPISchema", () => { describe("primitive types", () => { test("should convert ZodString", () => { const schema = z.string(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); }); test("should convert ZodString with email format", () => { const schema = z.string().email(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); expect(result.format).toBe("email"); }); test("should convert ZodString with uuid format", () => { const schema = z.string().uuid(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); expect(result.format).toBe("uuid"); }); test("should convert ZodString with datetime format", () => { const schema = z.string().datetime(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); expect(result.format).toBe("date-time"); }); test("should convert ZodString with min/max length", () => { const schema = z.string().min(2).max(100); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); expect(result.minLength).toBe(2); expect(result.maxLength).toBe(100); }); test("should convert ZodNumber", () => { const schema = z.number(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("number"); }); test("should convert ZodNumber with int", () => { const schema = z.number().int(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("integer"); }); test("should convert ZodNumber with min/max", () => { const schema = z.number().min(1).max(100); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("number"); expect(result.minimum).toBe(1); expect(result.maximum).toBe(100); }); test("should convert ZodBoolean", () => { const schema = z.boolean(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("boolean"); }); }); describe("complex types", () => { test("should convert ZodArray", () => { const schema = z.array(z.string()); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("array"); expect(result.items).toEqual({ type: "string" }); }); test("should convert ZodObject", () => { const schema = z.object({ name: z.string(), age: z.number(), }); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("object"); expect(result.properties).toBeDefined(); expect(result.properties!.name).toEqual({ type: "string" }); expect(result.properties!.age).toEqual({ type: "number" }); expect(result.required).toContain("name"); expect(result.required).toContain("age"); }); test("should convert ZodObject with optional fields", () => { const schema = z.object({ name: z.string(), nickname: z.string().optional(), }); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("object"); expect(result.required).toContain("name"); expect(result.required).not.toContain("nickname"); }); test("should convert ZodEnum", () => { const schema = z.enum(["admin", "user", "guest"]); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); expect(result.enum).toEqual(["admin", "user", "guest"]); }); test("should convert ZodUnion", () => { const schema = z.union([z.string(), z.number()]); const result = zodToOpenAPISchema(schema); expect(result.oneOf).toBeDefined(); expect(result.oneOf!.length).toBe(2); expect(result.oneOf![0]).toEqual({ type: "string" }); expect(result.oneOf![1]).toEqual({ type: "number" }); }); }); describe("modifiers", () => { test("should convert ZodOptional — unwraps without adding nullable (optional != nullable)", () => { const schema = z.string().optional(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); // Optionality is expressed by the parent object's required[] / parameter.required, // NOT by marking the field nullable. See generator.ts comment. expect(result.nullable).toBeUndefined(); }); test("should convert ZodNullable", () => { const schema = z.string().nullable(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); expect(result.nullable).toBe(true); }); test("should convert ZodOptional(ZodNullable) — inner nullable preserved", () => { const schema = z.string().nullable().optional(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("string"); expect(result.nullable).toBe(true); }); test("object with optional field — field omitted from required[] but schema itself is not nullable", () => { const schema = z.object({ name: z.string(), age: z.number().optional(), }); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("object"); expect(result.required).toEqual(["name"]); expect(result.properties?.age).toEqual({ type: "number" }); }); test("should convert ZodDefault", () => { const schema = z.number().default(10); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("number"); expect(result.default).toBe(10); }); test("should handle coerce", () => { const schema = z.coerce.number(); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("number"); }); }); describe("nested schemas", () => { test("should convert nested object", () => { const schema = z.object({ user: z.object({ name: z.string(), email: z.string().email(), }), posts: z.array( z.object({ id: z.number(), title: z.string(), }) ), }); const result = zodToOpenAPISchema(schema); expect(result.type).toBe("object"); expect(result.properties!.user.type).toBe("object"); expect(result.properties!.user.properties!.name.type).toBe("string"); expect(result.properties!.user.properties!.email.format).toBe("email"); expect(result.properties!.posts.type).toBe("array"); expect(result.properties!.posts.items!.type).toBe("object"); }); }); }); describe("openAPIToJSON", () => { test("should convert OpenAPI document to JSON", () => { const doc = { openapi: "3.0.3" as const, info: { title: "Test API", version: "1.0.0", }, paths: { "/users": { get: { summary: "List users", responses: { "200": { description: "OK", }, }, }, }, }, }; const json = openAPIToJSON(doc); const parsed = JSON.parse(json); expect(parsed.openapi).toBe("3.0.3"); expect(parsed.info.title).toBe("Test API"); expect(parsed.paths["/users"].get.summary).toBe("List users"); }); }); describe("Real-world contract conversion", () => { test("should convert complex user contract", () => { const UserSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(2).max(100), role: z.enum(["admin", "user", "guest"]), createdAt: z.string().datetime(), metadata: z .object({ lastLogin: z.string().datetime().optional(), loginCount: z.number().int().min(0).default(0), }) .optional(), }); const result = zodToOpenAPISchema(UserSchema); expect(result.type).toBe("object"); expect(result.properties!.id.format).toBe("uuid"); expect(result.properties!.email.format).toBe("email"); expect(result.properties!.name.minLength).toBe(2); expect(result.properties!.name.maxLength).toBe(100); expect(result.properties!.role.enum).toEqual(["admin", "user", "guest"]); expect(result.properties!.createdAt.format).toBe("date-time"); expect(result.required).toContain("id"); expect(result.required).toContain("email"); expect(result.required).not.toContain("metadata"); }); test("should convert paginated response schema", () => { const PaginatedSchema = z.object({ data: z.array(z.object({ id: z.number(), name: z.string() })), pagination: z.object({ page: z.number().int().min(1), limit: z.number().int().min(1).max(100), total: z.number().int(), totalPages: z.number().int(), }), }); const result = zodToOpenAPISchema(PaginatedSchema); expect(result.type).toBe("object"); expect(result.properties!.data.type).toBe("array"); expect(result.properties!.data.items!.type).toBe("object"); expect(result.properties!.pagination.type).toBe("object"); expect(result.properties!.pagination.properties!.page.type).toBe("integer"); expect(result.properties!.pagination.properties!.page.minimum).toBe(1); }); }); // ============================================ // End-to-end: manifest → OpenAPI → disk artifacts // ============================================ /** * Create a scratch project on disk with one API contract so we can * exercise the full build pipeline (generate + write + read) without * faking the dynamic import path. */ async function buildFixture(): Promise<{ rootDir: string; manifest: RoutesManifest; cleanup: () => Promise }> { const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-")); await linkWorkspaceNodeModules(rootDir); const contractPath = "contracts/users.contract.ts"; const contractAbs = path.join(rootDir, contractPath); await fs.mkdir(path.dirname(contractAbs), { recursive: true }); await fs.writeFile( contractAbs, `import { z } from "zod"; export default { name: "users", description: "List / create users", tags: ["users"], request: { GET: { query: z.object({ limit: z.number().int().min(1).max(100).optional() }), }, POST: { body: z.object({ name: z.string().min(2), email: z.string().email(), }), }, }, response: { 200: z.object({ items: z.array(z.object({ id: z.string().uuid(), name: z.string() })), }), 201: z.object({ id: z.string().uuid() }), 400: z.object({ error: z.string() }), }, }; `, "utf-8" ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "api/users", pattern: "/api/users", kind: "api", module: contractPath, contractModule: contractPath, methods: ["GET", "POST"], }, ], }; return { rootDir, manifest, cleanup: async () => { await fs.rm(rootDir, { recursive: true, force: true }); }, }; } describe("generateOpenAPIDocument", () => { test("should produce a valid OpenAPI 3.0.3 document with paths + methods + schemas", async () => { const { rootDir, manifest, cleanup } = await buildFixture(); try { const doc = await generateOpenAPIDocument(manifest, rootDir, { title: "Users API", version: "2.1.0", }); expect(doc.openapi).toBe("3.0.3"); expect(doc.info.title).toBe("Users API"); expect(doc.info.version).toBe("2.1.0"); // The `/api/users` path should carry both GET and POST operations. expect(doc.paths["/api/users"]).toBeDefined(); expect(doc.paths["/api/users"].get).toBeDefined(); expect(doc.paths["/api/users"].post).toBeDefined(); // GET query param `limit` should be captured. const getParams = doc.paths["/api/users"].get!.parameters ?? []; const limitParam = getParams.find((p) => p.name === "limit"); expect(limitParam).toBeDefined(); expect(limitParam!.in).toBe("query"); // POST body should be captured with JSON content. const postBody = doc.paths["/api/users"].post!.requestBody; expect(postBody).toBeDefined(); expect(postBody!.content["application/json"]).toBeDefined(); // Response schemas should be present. const okResponse = doc.paths["/api/users"].get!.responses["200"]; expect(okResponse).toBeDefined(); expect(okResponse.content!["application/json"]).toBeDefined(); } finally { await cleanup(); } }); }); describe("hashOpenAPIJSON", () => { test("should produce a deterministic 64-char hex digest", async () => { const sample = JSON.stringify({ hello: "world" }); const hashA = await hashOpenAPIJSON(sample); const hashB = await hashOpenAPIJSON(sample); expect(hashA).toBe(hashB); expect(hashA).toMatch(/^[0-9a-f]{64}$/); }); test("should change with input", async () => { const hashA = await hashOpenAPIJSON(JSON.stringify({ v: 1 })); const hashB = await hashOpenAPIJSON(JSON.stringify({ v: 2 })); expect(hashA).not.toBe(hashB); }); }); describe("openAPIToYAML", () => { test("should emit a parseable two-space-indented YAML subset", () => { const doc = { openapi: "3.0.3" as const, info: { title: "Test API", version: "1.0.0" }, paths: { "/api/users": { get: { summary: "List", responses: { "200": { description: "OK" } }, }, }, }, }; const yaml = openAPIToYAML(doc); // Must NOT contain raw curly braces from the old naive converter. expect(yaml).toContain("openapi: 3.0.3"); expect(yaml).toContain("info:"); expect(yaml).toContain(" title: Test API"); expect(yaml).toContain(" version: 1.0.0"); expect(yaml).toContain("paths:"); // Path key contains `/` and `:` suffix is handled as block mapping, // so the OpenAPI path appears as a block-mapping key — expect its // presence without assuming quoting. expect(yaml).toMatch(/\/api\/users:/); expect(yaml).toContain(" summary: List"); expect(yaml).toMatch(/\n$/); }); }); describe("writeOpenAPIArtifacts + readOpenAPIArtifacts", () => { test("should write openapi.json and openapi.yaml then read them back with matching hash", async () => { const { rootDir, manifest, cleanup } = await buildFixture(); try { const written = await writeOpenAPIArtifacts(manifest, rootDir, ".mandu", { title: "Artifact Test", version: "1.0.0", }); expect(written.paths.json.endsWith("openapi.json")).toBe(true); expect(written.paths.yaml.endsWith("openapi.yaml")).toBe(true); expect(written.hash).toMatch(/^[0-9a-f]{64}$/); expect(written.pathCount).toBeGreaterThan(0); // Both files should exist on disk. const jsonOnDisk = await fs.readFile(written.paths.json, "utf-8"); const yamlOnDisk = await fs.readFile(written.paths.yaml, "utf-8"); expect(jsonOnDisk).toBe(written.json); expect(yamlOnDisk).toBe(written.yaml); // JSON should parse. const parsed = JSON.parse(jsonOnDisk); expect(parsed.openapi).toBe("3.0.3"); expect(parsed.info.title).toBe("Artifact Test"); // readOpenAPIArtifacts should recover the same bodies and recompute // the same hash. const readBack = await readOpenAPIArtifacts(".mandu", rootDir); expect(readBack).not.toBeNull(); expect(readBack!.hash).toBe(written.hash); expect(readBack!.json).toBe(written.json); expect(readBack!.yaml).toBe(written.yaml); } finally { await cleanup(); } }); test("readOpenAPIArtifacts returns null when artifacts are missing", async () => { const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-miss-")); try { const result = await readOpenAPIArtifacts(".mandu", rootDir); expect(result).toBeNull(); } finally { await fs.rm(rootDir, { recursive: true, force: true }); } }); }); // ============================================ // Schema hoisting (shared schemas → components.schemas) // ============================================ /** * Build a two-contract fixture where both contracts share the same * `User` body shape. The generator should hoist `{ name, email }` into * `components.schemas` when hoisting is enabled. */ async function buildSharedSchemaFixture(opts: { /** Override the `name` attribute on each contract (in file order). */ contractNames?: [string, string]; /** Replace the second contract's body shape to exercise collision paths. */ secondBody?: string; } = {}): Promise<{ rootDir: string; manifest: RoutesManifest; cleanup: () => Promise; }> { const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-hoist-")); await linkWorkspaceNodeModules(rootDir); const [nameA, nameB] = opts.contractNames ?? ["users", "admins"]; const bodyB = opts.secondBody ?? `z.object({ name: z.string().min(2), email: z.string().email() })`; await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true }); await fs.writeFile( path.join(rootDir, "contracts/users.contract.ts"), `import { z } from "zod"; export default { name: "${nameA}", request: { POST: { body: z.object({ name: z.string().min(2), email: z.string().email() }), }, }, response: { 200: z.object({ id: z.string().uuid(), status: z.string() }), }, }; `, "utf-8" ); await fs.writeFile( path.join(rootDir, "contracts/admins.contract.ts"), `import { z } from "zod"; export default { name: "${nameB}", request: { POST: { body: ${bodyB}, }, }, response: { 200: z.object({ id: z.string().uuid(), status: z.string() }), }, }; `, "utf-8" ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "api/users", pattern: "/api/users", kind: "api", module: "contracts/users.contract.ts", contractModule: "contracts/users.contract.ts", methods: ["POST"], }, { id: "api/admins", pattern: "/api/admins", kind: "api", module: "contracts/admins.contract.ts", contractModule: "contracts/admins.contract.ts", methods: ["POST"], }, ], }; return { rootDir, manifest, cleanup: async () => { await fs.rm(rootDir, { recursive: true, force: true }); }, }; } describe("hoistSharedSchemas", () => { test("two routes sharing a body shape emit a single components.schemas entry with $ref", async () => { const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture(); try { const doc = await generateOpenAPIDocument(manifest, rootDir, { title: "Hoist Test", }); // The shared POST body should be hoisted. There are also shared // 200 responses so we expect at least 2 hoisted entries. expect(doc.components?.schemas).toBeDefined(); const schemaNames = Object.keys(doc.components!.schemas!); expect(schemaNames.length).toBeGreaterThanOrEqual(1); const usersBody = doc.paths["/api/users"].post!.requestBody!.content["application/json"].schema; const adminsBody = doc.paths["/api/admins"].post!.requestBody!.content["application/json"].schema; // Both bodies must now be $ref pointers into components.schemas. expect(usersBody.$ref).toBeDefined(); expect(adminsBody.$ref).toBeDefined(); // ... and they must point to the exact same entry (shared shape). expect(usersBody.$ref).toBe(adminsBody.$ref); // The pointed-to entry must exist and retain the object shape. const refTarget = usersBody.$ref!.replace("#/components/schemas/", ""); const hoisted = doc.components!.schemas![refTarget]; expect(hoisted.type).toBe("object"); expect(hoisted.properties!.name).toBeDefined(); expect(hoisted.properties!.email).toBeDefined(); } finally { await cleanup(); } }); test("one-off schema stays inline (no hoist, no components.schemas entry for it)", async () => { const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-oneoff-")); try { await linkWorkspaceNodeModules(rootDir); await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true }); await fs.writeFile( path.join(rootDir, "contracts/solo.contract.ts"), `import { z } from "zod"; export default { name: "solo", request: { POST: { body: z.object({ uniqueField: z.string() }) }, }, response: { 200: z.object({ ok: z.boolean() }) }, }; `, "utf-8" ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "api/solo", pattern: "/api/solo", kind: "api", module: "contracts/solo.contract.ts", contractModule: "contracts/solo.contract.ts", methods: ["POST"], }, ], }; const doc = await generateOpenAPIDocument(manifest, rootDir); const body = doc.paths["/api/solo"].post!.requestBody!.content["application/json"].schema; // Solo schemas must NOT be hoisted — stays inline. expect(body.$ref).toBeUndefined(); expect(body.type).toBe("object"); expect(body.properties?.uniqueField).toBeDefined(); // components.schemas may exist (e.g. for the default 500 shape if it // happened to collide with another schema), but the one-off body's // uniqueField shape must not appear as a hoisted entry. const schemas = doc.components?.schemas ?? {}; for (const entry of Object.values(schemas)) { expect(entry.properties?.uniqueField).toBeUndefined(); } } finally { await fs.rm(rootDir, { recursive: true, force: true }); } }); test("same shape with different user-given names collapses to one entry (first-come wins)", async () => { const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture({ contractNames: ["users", "admins"], }); try { const doc = await generateOpenAPIDocument(manifest, rootDir); const usersBody = doc.paths["/api/users"].post!.requestBody!.content["application/json"].schema; const adminsBody = doc.paths["/api/admins"].post!.requestBody!.content["application/json"].schema; // Both must resolve to the *same* $ref — structural identity wins // over the user-given name split. expect(usersBody.$ref).toBeDefined(); expect(usersBody.$ref).toBe(adminsBody.$ref); // Exactly one body component (not two) for this shape. Count entries // that match `{name,email}` shape. const sharedEntries = Object.values(doc.components!.schemas!).filter( (s) => s.properties?.name && s.properties?.email ); expect(sharedEntries.length).toBe(1); } finally { await cleanup(); } }); test("name collision across structurally-different schemas → _v2 suffix", () => { // Hand-craft a doc with two different body shapes whose name hints // would collide. We exercise the hoist pass directly so we can // control the hints without routing through Zod. const shapeA = { type: "object" as const, properties: { a: { type: "string" as const } }, required: ["a"], }; const shapeB = { type: "object" as const, properties: { b: { type: "number" as const } }, required: ["b"], }; // Two routes use shape A, two use shape B — every schema gets the // same name hint "Shared". The second winning hash should be renamed // to "Shared_v2". const a1 = { ...shapeA }; const a2 = { ...shapeA }; const b1 = { ...shapeB }; const b2 = { ...shapeB }; const doc: OpenAPIDocument = { openapi: "3.0.3", info: { title: "t", version: "1.0.0" }, paths: { "/a1": { post: { responses: {}, requestBody: { content: { "application/json": { schema: a1 } } }, }, }, "/a2": { post: { responses: {}, requestBody: { content: { "application/json": { schema: a2 } } }, }, }, "/b1": { post: { responses: {}, requestBody: { content: { "application/json": { schema: b1 } } }, }, }, "/b2": { post: { responses: {}, requestBody: { content: { "application/json": { schema: b2 } } }, }, }, }, }; // Attach the same hint to both shapes via hoistSharedSchemas's own // name-hint mechanism: we can't from here (WeakMap is module-private). // So we instead rely on the deterministic hash-based fallback name, // then assert both shapes are hoisted and receive distinct entries. return hoistSharedSchemas(doc).then(() => { const schemas = doc.components?.schemas ?? {}; const names = Object.keys(schemas); // Both shapes qualify (appear twice each) → 2 hoisted entries. expect(names.length).toBe(2); // No two entries share the exact same name. expect(new Set(names).size).toBe(names.length); // The generated names must be stable strings (start with `Schema_` // in the fallback path). for (const n of names) { expect(n).toMatch(/^Schema_[0-9a-f]{8}$/); } }); }); test("hoistSchemas: false produces the legacy inline-only document", async () => { const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture(); try { const docOn = await generateOpenAPIDocument(manifest, rootDir, { hoistSchemas: true, }); const docOff = await generateOpenAPIDocument(manifest, rootDir, { hoistSchemas: false, }); // Off: no $refs anywhere, no components.schemas emitted. const offJson = JSON.stringify(docOff); expect(offJson.includes("$ref")).toBe(false); expect(docOff.components?.schemas).toBeUndefined(); // On: at least one $ref appeared. Confirms the two paths diverge. const onJson = JSON.stringify(docOn); expect(onJson.includes("$ref")).toBe(true); expect(docOn.components?.schemas).toBeDefined(); } finally { await cleanup(); } }); test("enum / primitive schemas are never hoisted", async () => { const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-enum-")); try { await linkWorkspaceNodeModules(rootDir); await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true }); const contractBody = `import { z } from "zod"; export default { name: NAME, request: { POST: { body: z.object({ role: z.enum(["a", "b", "c"]) }) }, }, response: { 200: z.object({ role: z.enum(["a", "b", "c"]) }) }, }; `; await fs.writeFile( path.join(rootDir, "contracts/one.contract.ts"), contractBody.replace("NAME", '"one"'), "utf-8" ); await fs.writeFile( path.join(rootDir, "contracts/two.contract.ts"), contractBody.replace("NAME", '"two"'), "utf-8" ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "api/one", pattern: "/api/one", kind: "api", module: "contracts/one.contract.ts", contractModule: "contracts/one.contract.ts", methods: ["POST"], }, { id: "api/two", pattern: "/api/two", kind: "api", module: "contracts/two.contract.ts", contractModule: "contracts/two.contract.ts", methods: ["POST"], }, ], }; const doc = await generateOpenAPIDocument(manifest, rootDir); // The outer {role: enum} object *is* hoistable and shared — that's fine. // The inner enum itself must NOT be hoisted — scan every component // entry and assert none of them is a bare enum-typed schema. for (const entry of Object.values(doc.components?.schemas ?? {})) { expect(entry.enum).toBeUndefined(); if (entry.type === "string" && !entry.properties) { throw new Error(`Primitive string schema should not be hoisted: ${JSON.stringify(entry)}`); } } } finally { await fs.rm(rootDir, { recursive: true, force: true }); } }); test("hoistThreshold: 3 → 2-use schemas stay inline; 3-use schemas get hoisted", () => { // Doc with the same shape appearing exactly twice. const shape = { type: "object" as const, properties: { x: { type: "string" as const } }, required: ["x"], }; const buildDoc = (): OpenAPIDocument => ({ openapi: "3.0.3", info: { title: "t", version: "1.0.0" }, paths: { "/a": { post: { responses: {}, requestBody: { content: { "application/json": { schema: { ...shape } } } }, }, }, "/b": { post: { responses: {}, requestBody: { content: { "application/json": { schema: { ...shape } } } }, }, }, }, }); const doc2 = buildDoc(); return hoistSharedSchemas(doc2, { threshold: 3 }).then(() => { // With threshold=3 and only 2 uses → nothing hoisted. expect(doc2.components?.schemas).toBeUndefined(); expect(doc2.paths["/a"].post!.requestBody!.content["application/json"].schema.$ref).toBeUndefined(); // Re-run with threshold=2 on a fresh doc → the same shape is hoisted. const doc1 = buildDoc(); return hoistSharedSchemas(doc1, { threshold: 2 }).then(() => { expect(doc1.components?.schemas).toBeDefined(); expect(Object.keys(doc1.components!.schemas!).length).toBe(1); }); }); }); test("threshold values < 2 clamp up to 2 (never hoist single-use schemas)", () => { // Edge: a malicious / misconfigured `hoistThreshold: 1` would otherwise // hoist every schema including one-offs, blowing up the spec. const shape = { type: "object" as const, properties: { solo: { type: "string" as const } }, required: ["solo"], }; const doc: OpenAPIDocument = { openapi: "3.0.3", info: { title: "t", version: "1.0.0" }, paths: { "/only": { post: { responses: {}, requestBody: { content: { "application/json": { schema: { ...shape } } } }, }, }, }, }; return hoistSharedSchemas(doc, { threshold: 1 }).then(() => { expect(doc.components?.schemas).toBeUndefined(); expect(doc.paths["/only"].post!.requestBody!.content["application/json"].schema.$ref).toBeUndefined(); }); }); });