234 lines
7.9 KiB
TypeScript
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");
|
|
});
|
|
});
|