Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

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