Files
suaveplan/packages/api-stack/api/api-openapi/src/merger/merger.test.ts
2026-05-27 23:29:53 +01:00

234 lines
7.9 KiB
TypeScript

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");
});
});