Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
194 lines
6.8 KiB
TypeScript
194 lines
6.8 KiB
TypeScript
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<string, unknown>)?.[method] as
|
|
| { responses?: Record<string, unknown>; 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<string, unknown>)?.[method] as
|
|
| { responses?: Record<string, Record<string, unknown>> }
|
|
| undefined;
|
|
if (!op?.responses) continue;
|
|
|
|
for (const [statusCode, response] of Object.entries(op.responses)) {
|
|
if (!statusCode.startsWith("2")) continue;
|
|
|
|
const content = (response as Record<string, unknown>)?.content as
|
|
| Record<string, { schema?: Record<string, unknown> }>
|
|
| 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<string, unknown>)?.properties as
|
|
| Record<string, unknown>
|
|
| 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<string, unknown>)?.[method] as
|
|
| { responses?: Record<string, unknown> }
|
|
| 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<string, unknown>)?.[method] as
|
|
| { operationId?: string }
|
|
| undefined;
|
|
if (op?.operationId) {
|
|
operationIds.push(op.operationId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
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<string, unknown>)?.[method] as
|
|
| { responses?: Record<string, Record<string, unknown>> }
|
|
| 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<string, unknown>)?.content as
|
|
| Record<string, { schema?: Record<string, unknown> }>
|
|
| undefined;
|
|
if (!content?.["application/json"]) continue;
|
|
|
|
const schema = content["application/json"].schema;
|
|
expect(
|
|
schema,
|
|
`${method.toUpperCase()} ${path} ${statusCode} error missing schema`,
|
|
).toBeDefined();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|