Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
193
packages/core/tests/integration/openapi/openapi.test.ts
Normal file
193
packages/core/tests/integration/openapi/openapi.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user