import { OpenAPIHono } from "@hono/zod-openapi"; import { describe, expect, it } from "@suaveplan/testing/runner"; import type { OpenAPIDocument } from "../types/openapi.js"; import { extractOpenAPIDoc, filterPathsByTag, mergeOpenAPIDocuments, prefixPaths, removeInternalRoutes, } from "./merger.js"; const baseDoc = (): OpenAPIDocument => ({ openapi: "3.1.0", info: { title: "T", version: "1.0.0" }, paths: { "/a": { get: { responses: { "200": { description: "ok" } } } } }, tags: [{ name: "alpha" }], }); describe("mergeOpenAPIDocuments", () => { it("merge composes paths", { timeout: 5000 }, () => { const merged = mergeOpenAPIDocuments(baseDoc(), { paths: { "/b": { post: { responses: {} } }, }, }); expect(Object.keys(merged.paths).sort()).toEqual(["/a", "/b"]); }); it("colliding paths union their methods", { timeout: 5000 }, () => { const merged = mergeOpenAPIDocuments(baseDoc(), { paths: { "/a": { post: { responses: {} } } }, }); expect(merged.paths["/a"]?.get).toBeDefined(); expect(merged.paths["/a"]?.post).toBeDefined(); }); it("colliding methods — last write wins", { timeout: 5000 }, () => { const merged = mergeOpenAPIDocuments(baseDoc(), { paths: { "/a": { get: { responses: { "200": { description: "B" } } }, }, }, }); const responses = merged.paths["/a"]?.get?.responses as Record< string, { description: string } >; expect(responses["200"]?.description).toBe("B"); }); it("dedups tags by name", { timeout: 5000 }, () => { const merged = mergeOpenAPIDocuments(baseDoc(), { tags: [{ name: "alpha" }, { name: "beta" }], }); expect(merged.tags?.map((t) => t.name).sort()).toEqual([ "alpha", "beta", ]); }); it("spread-merges components", { timeout: 5000 }, () => { const base = { ...baseDoc(), components: { schemas: { User: { type: "object" } } }, }; const merged = mergeOpenAPIDocuments(base, { components: { schemas: { Project: { type: "object" } }, securitySchemes: { Bearer: { type: "http" } }, parameters: { idParam: {} }, responses: { default: {} }, }, }); expect(merged.components?.schemas).toMatchObject({ User: { type: "object" }, Project: { type: "object" }, }); expect(merged.components?.securitySchemes).toMatchObject({ Bearer: { type: "http" }, }); expect(merged.components?.parameters?.["idParam"]).toBeDefined(); expect(merged.components?.responses?.["default"]).toBeDefined(); }); it("concats servers from add-on docs", { timeout: 5000 }, () => { const base = { ...baseDoc(), servers: [{ url: "https://a" }] }; const merged = mergeOpenAPIDocuments(base, { servers: [{ url: "https://b" }], }); expect(merged.servers?.map((s) => s.url).sort()).toEqual([ "https://a", "https://b", ]); }); it("works without components on base", { timeout: 5000 }, () => { const merged = mergeOpenAPIDocuments(baseDoc(), { components: { schemas: { X: {} } }, }); expect(merged.components?.schemas?.["X"]).toBeDefined(); }); it("doc.components with only securitySchemes (no other sub-keys)", { timeout: 5000, }, () => { const merged = mergeOpenAPIDocuments(baseDoc(), { components: { securitySchemes: { Bearer: { type: "http" } } }, }); expect(merged.components?.securitySchemes).toMatchObject({ Bearer: { type: "http" }, }); expect(merged.components?.schemas).toEqual({}); }); it("doc.servers merges into a base without servers", { timeout: 5000, }, () => { const merged = mergeOpenAPIDocuments(baseDoc(), { servers: [{ url: "https://only" }], }); expect(merged.servers?.map((s) => s.url)).toEqual(["https://only"]); }); it("merging with no docs returns a clone with identical paths/info/tags", { timeout: 5000, }, () => { const b = baseDoc(); const m = mergeOpenAPIDocuments(b); expect(m).not.toBe(b); expect(m.paths).toEqual(b.paths); expect(m.info).toEqual(b.info); expect(m.tags).toEqual(b.tags); }); }); describe("prefixPaths", () => { it("prefix collapses double slashes", { timeout: 5000 }, () => { const out = prefixPaths(baseDoc(), "/api/"); expect(Object.keys(out.paths)).toEqual(["/api/a"]); }); it("prefix without trailing slash", { timeout: 5000 }, () => { const out = prefixPaths(baseDoc(), "/v1"); expect(Object.keys(out.paths)).toEqual(["/v1/a"]); }); }); describe("filterPathsByTag", () => { it("keeps tag-intersecting ops", { timeout: 5000 }, () => { const doc: OpenAPIDocument = { openapi: "3.1.0", info: { title: "T", version: "1.0.0" }, paths: { "/a": { get: { responses: {}, tags: ["alpha"] }, post: { responses: {}, tags: ["beta"] }, }, "/b": { get: { responses: {}, tags: ["beta"] } }, }, tags: [{ name: "alpha" }, { name: "beta" }], }; const out = filterPathsByTag(doc, ["alpha"]); expect(out.paths["/a"]?.get).toBeDefined(); expect(out.paths["/a"]?.post).toBeUndefined(); expect(out.paths["/b"]).toBeUndefined(); expect(out.tags?.map((t) => t.name)).toEqual(["alpha"]); }); it("drops the tags array gracefully when absent", { timeout: 5000, }, () => { const doc: OpenAPIDocument = { openapi: "3.1.0", info: { title: "T", version: "1.0.0" }, paths: { "/a": { get: { responses: {}, tags: ["x"] } } }, }; const out = filterPathsByTag(doc, ["x"]); expect(out.paths["/a"]).toBeDefined(); expect(out.tags).toBeUndefined(); }); }); describe("removeInternalRoutes", () => { it("drops x-internal: true", { timeout: 5000 }, () => { const doc: OpenAPIDocument = { openapi: "3.1.0", info: { title: "T", version: "1.0.0" }, paths: { "/pub": { get: { responses: {} } }, "/int": { get: { responses: {}, ["x-internal" as `x-${string}`]: true, }, }, }, }; const out = removeInternalRoutes(doc); expect(out.paths["/pub"]).toBeDefined(); expect(out.paths["/int"]).toBeUndefined(); }); it("keeps non-internal methods even when sibling is internal", { timeout: 5000, }, () => { const doc: OpenAPIDocument = { openapi: "3.1.0", info: { title: "T", version: "1.0.0" }, paths: { "/x": { get: { responses: {} }, post: { responses: {}, ["x-internal" as `x-${string}`]: true, }, }, }, }; const out = removeInternalRoutes(doc); expect(out.paths["/x"]?.get).toBeDefined(); expect(out.paths["/x"]?.post).toBeUndefined(); }); }); describe("extractOpenAPIDoc", () => { it("extracts a doc from a Hono app", { timeout: 5000 }, () => { const app = new OpenAPIHono(); const doc = extractOpenAPIDoc(app, { title: "X", version: "1.0.0" }); expect(doc.openapi).toBe("3.1.0"); expect(doc.info.title).toBe("X"); }); });