import SwaggerParser from "@apidevtools/swagger-parser"; import { describe, expect, it } from "vitest"; import { generateOpenApiDocument } from "../../../src/api/openapi/document.js"; describe("OpenAPI spec validation", () => { it("produces a valid OpenAPI 3.1 document", async () => { const doc = generateOpenApiDocument(); // swagger-parser.validate() resolves $refs and validates against the OAS JSON Schema. // It throws if the document is invalid. const validated = await SwaggerParser.validate(structuredClone(doc)); expect(validated.openapi).toBe("3.1.0"); expect(validated.info.title).toBe("EmDash CMS API"); }); it("resolves all $ref pointers without errors", async () => { const doc = generateOpenApiDocument(); // dereference() resolves every $ref in the document tree. // If any $ref points to a missing schema, it throws. const dereferenced = await SwaggerParser.dereference(structuredClone(doc)); // After dereferencing, no $ref keys should remain. // Use a replacer to handle circular references (e.g. PublicComment.replies) const seen = new WeakSet(); const json = JSON.stringify(dereferenced, (_key, value) => { if (typeof value === "object" && value !== null) { if (seen.has(value)) return "[Circular]"; seen.add(value); } return value; }); expect(json).not.toContain('"$ref"'); }); it("has all content paths with responses", () => { const doc = generateOpenApiDocument(); const paths = doc.paths ?? {}; for (const [path, pathItem] of Object.entries(paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const op = (pathItem as Record)?.[method] as | { responses?: Record; operationId?: string } | undefined; if (!op) continue; // Every operation must have responses expect(op.responses, `${method.toUpperCase()} ${path} missing responses`).toBeDefined(); // Every operation must have an operationId expect(op.operationId, `${method.toUpperCase()} ${path} missing operationId`).toBeDefined(); // Every operation must have at least one success response (2xx) const statusCodes = Object.keys(op.responses ?? {}); const has2xx = statusCodes.some((code) => code.startsWith("2")); expect(has2xx, `${method.toUpperCase()} ${path} has no 2xx response`).toBe(true); } } }); it("wraps all success responses in the { data } envelope", () => { const doc = generateOpenApiDocument(); const paths = doc.paths ?? {}; for (const [path, pathItem] of Object.entries(paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const op = (pathItem as Record)?.[method] as | { responses?: Record> } | undefined; if (!op?.responses) continue; for (const [statusCode, response] of Object.entries(op.responses)) { if (!statusCode.startsWith("2")) continue; const content = (response as Record)?.content as | Record }> | undefined; if (!content?.["application/json"]) continue; const schema = content["application/json"].schema; expect( schema, `${method.toUpperCase()} ${path} ${statusCode} missing schema`, ).toBeDefined(); // The envelope must have a "data" property (either directly or via $ref that wraps it) // Check for direct properties or allOf/oneOf patterns const props = (schema as Record)?.properties as | Record | undefined; if (props) { expect( props, `${method.toUpperCase()} ${path} ${statusCode} envelope missing "data" property`, ).toHaveProperty("data"); } } } } }); it("includes auth error responses on authenticated endpoints", () => { const doc = generateOpenApiDocument(); const paths = doc.paths ?? {}; // Public endpoints that don't require authentication const publicPaths = new Set(["/_emdash/api/comments/{collection}/{contentId}"]); for (const [path, pathItem] of Object.entries(paths)) { if (publicPaths.has(path)) continue; for (const method of ["get", "post", "put", "delete", "patch"] as const) { const op = (pathItem as Record)?.[method] as | { responses?: Record } | undefined; if (!op?.responses) continue; const statusCodes = Object.keys(op.responses); expect(statusCodes, `${method.toUpperCase()} ${path} missing 401`).toContain("401"); expect(statusCodes, `${method.toUpperCase()} ${path} missing 403`).toContain("403"); } } }); it("has no duplicate operation IDs across all paths", () => { const doc = generateOpenApiDocument(); const operationIds: string[] = []; for (const pathItem of Object.values(doc.paths ?? {})) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const op = (pathItem as Record)?.[method] as | { operationId?: string } | undefined; if (op?.operationId) { operationIds.push(op.operationId); } } } const seen = new Set(); for (const id of operationIds) { expect(seen.has(id), `duplicate operationId: ${id}`).toBe(false); seen.add(id); } }); it("registers referenced schemas as reusable components", async () => { const doc = generateOpenApiDocument(); const schemas = doc.components?.schemas ?? {}; const schemaNames = Object.keys(schemas); // Should have a reasonable number of reusable schemas expect(schemaNames.length).toBeGreaterThanOrEqual(5); // All registered schemas should be valid objects with type or properties for (const [name, schema] of Object.entries(schemas)) { expect(schema, `component schema "${name}" is not an object`).toBeTypeOf("object"); } }); it("uses consistent error response shape across all error codes", () => { const doc = generateOpenApiDocument(); const paths = doc.paths ?? {}; for (const [path, pathItem] of Object.entries(paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const op = (pathItem as Record)?.[method] as | { responses?: Record> } | undefined; if (!op?.responses) continue; for (const [statusCode, response] of Object.entries(op.responses)) { // Only check error responses (4xx, 5xx) const code = Number(statusCode); if (code < 400) continue; const content = (response as Record)?.content as | Record }> | undefined; if (!content?.["application/json"]) continue; const schema = content["application/json"].schema; expect( schema, `${method.toUpperCase()} ${path} ${statusCode} error missing schema`, ).toBeDefined(); } } } }); });