Files
Reactive-Resume/apps/server/src/openapi/generator.test.ts
T
Amruth Pillai 2506509538 feat: make cover letters documents of their own
Resumes no longer hold cover-letter sections. A migration saves every
letter a resume carried as a letter linked to that resume's details and
design, hands a resume's only letter to its only letter-less application,
then removes the sections from resumes and their layouts. rollback.sql
puts them back.

Every resume write on the server moves any letter it still carries into
a saved letter in the same transaction, so stale tabs, older files, API
clients and restored versions keep working without duplicating letters.

The resume editor no longer adds or imports letters, the resume download
drops its Cover letter tab, and resume PDF downloads (API, signed links,
MCP) no longer take a cover-letter target. copy_embedded_cover_letter and
POST /cover-letters/from-resume are removed.
2026-09-29 09:30:07 +02:00

264 lines
9.2 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
// Spec generation reads procedure contracts without executing authentication. Keep the
// provider's resource seeding out of this unit test; real OAuth initialization is covered
// by the opt-in PostgreSQL integration suite after migrations run.
vi.mock("@reactive-resume/auth/config", () => ({ auth: {}, verifyOAuthToken: vi.fn() }));
type GeneratedSpecView = {
components?: { schemas?: Record<string, unknown> };
paths?: Record<
string,
Record<
string,
{
tags?: string[];
operationId?: string;
summary?: string;
description?: string;
responses?: Record<string, { description?: string }>;
requestBody?: {
content?: Record<string, { schema?: unknown }>;
};
}
>
>;
};
// Building the spec walks every router and resume JSON schema, which costs seconds. It is
// deterministic and every test here only reads it, so generate it once for the whole file —
// regenerating per test made the first case time out under a loaded machine.
let specPromise: ReturnType<typeof generateOnce> | undefined;
async function generateOnce() {
const { generateOpenApiSpec } = await import("./generator");
return generateOpenApiSpec({
appUrl: "https://rxresu.me",
version: "9.8.7",
});
}
function generateSpec() {
specPromise ??= generateOnce();
return specPromise;
}
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
}
function containsImpossibleSchema(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
if (typeof value !== "object" || value === null) return false;
const object = value as Record<string, unknown>;
const negated = object.not;
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
return true;
}
return Object.values(object).some(containsImpossibleSchema);
}
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
const impossibleRequests: string[] = [];
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(operations)) {
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
if (containsImpossibleSchema(content.schema)) {
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
}
}
}
}
return impossibleRequests;
}
describe("generateOpenApiSpec", () => {
it("documents all cover-letter procedures with REST metadata", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const expected = [
["get", "/cover-letters", "listCoverLetters", "List cover letters", "200"],
["get", "/cover-letters/{id}", "getCoverLetter", "Get cover letter by ID", "200"],
["post", "/cover-letters", "createCoverLetter", "Create a cover letter", "200"],
["put", "/cover-letters/{id}", "updateCoverLetter", "Update a cover letter", "200"],
["post", "/cover-letters/{id}/refresh-style", "refreshCoverLetterStyle", "Refresh cover letter style", "200"],
["post", "/cover-letters/{id}/duplicate", "duplicateCoverLetter", "Duplicate a cover letter", "200"],
["delete", "/cover-letters/{id}", "deleteCoverLetter", "Move a cover letter to Trash", "200"],
["get", "/cover-letters/{id}/export", "exportCoverLetter", "Export a cover letter", "200"],
["post", "/cover-letters/import", "importCoverLetter", "Import a cover letter", "200"],
["get", "/cover-letters/{id}/versions", "listCoverLetterVersions", "List a cover letter's versions", "200"],
[
"post",
"/cover-letters/{id}/versions/{versionId}/restore",
"restoreCoverLetterVersion",
"Restore a cover letter version",
"200",
],
] as const;
for (const [method, path, operationId, summary, successStatus] of expected) {
const operation = spec.paths?.[path]?.[method];
expect(operation).toMatchObject({
tags: ["Cover Letters"],
operationId,
summary,
description: expect.any(String),
responses: { [successStatus]: { description: expect.any(String) } },
});
}
}, 15_000);
it("keeps published cover-letter operations in sync with the runtime spec", async () => {
const published = JSON.parse(
await readFile(new URL("../../../../docs/spec.json", import.meta.url), "utf8"),
) as GeneratedSpecView;
const runtime = await generateSpec();
const coverLetterPaths = (spec: GeneratedSpecView) =>
Object.fromEntries(
Object.entries(spec.paths ?? {}).filter(
([path]) => path.startsWith("/cover-letters") || path.startsWith("/coverLetters/"),
),
);
const publishedPaths = coverLetterPaths(published);
const runtimePaths = coverLetterPaths(runtime as GeneratedSpecView);
expect(Object.keys(publishedPaths).sort()).toEqual(Object.keys(runtimePaths).sort());
expect(publishedPaths).toEqual(runtimePaths);
});
it("uses caller-provided application URL and version", async () => {
const spec = await generateSpec();
expect(spec.info).toMatchObject({
title: "Reactive Resume",
version: "9.8.7",
});
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
expect(spec.externalDocs).toEqual({
url: "https://docs.rxresu.me",
description: "Reactive Resume Documentation",
});
}, 15_000);
it("documents the public health endpoint at its actual URL", async () => {
const spec = await generateSpec();
const health = spec.paths?.["/api/health"]?.get;
expect(health).toMatchObject({
operationId: "getHealth",
security: [],
servers: [{ url: "https://rxresu.me" }],
});
for (const status of ["200", "503"]) {
expect(health?.responses?.[status]).toMatchObject({
content: {
"application/json": {
schema: {
required: expect.arrayContaining(["service", "version", "status"]),
properties: { version: { type: "string" } },
},
},
},
});
}
});
it("uses the canonical input-side ResumeData schema in update requests", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
});
});
it("accepts legacy input with omitted picture fit in the published request schema", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(spec.components?.schemas?.ResumeData).toMatchObject({
properties: {
picture: { required: expect.not.arrayContaining(["fit"]) },
},
});
});
it("publishes the custom-section type and item correlation", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
it("enforces the same submitted bounds as the published request schema", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const published = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
for (const marginX of [0, 100, -1, 500]) {
const data = structuredClone(defaultResumeData);
data.metadata.page.marginX = marginX;
const expected = marginX === 0 || marginX === 100;
expect(published.safeParse(data).success).toBe(expected);
expect(writableResumeDataSchema.safeParse(data).success).toBe(expected);
}
});
it("does not publish impossible request schemas", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
});
it("checks every request body media type for impossible schemas", () => {
const spec: GeneratedSpecView = {
paths: {
"/documents": {
post: {
requestBody: {
content: {
"application/json": { schema: { type: "object" } },
"multipart/form-data": { schema: { not: {} } },
},
},
},
},
},
};
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
});
it("documents imported data as an accepted ResumeData input", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
});
});