Files
Reactive-Resume/apps/server/src/openapi/generator.test.ts
T
Amruth Pillai 57171c6fcb feat(api): add REST endpoints for imports, exports, checks and pagination
Resource-oriented REST aliases cover file imports (PDF text layer, JSON
Resume, Reactive Resume v4/current, LinkedIn), PDF/DOCX/Markdown/JSON
exports for resumes and cover letters, editor content checks, job-term
matching and a public PDF readability checker. Collection endpoints accept
limit/offset with X-Total-Count headers, and PATCH routes cover partial
updates. Existing routes and response shapes stay unchanged.

The OpenAPI spec marks public operations with `security: []`, the REST
surface gets a 40 MiB body limit, and Better Auth publishes its own
OpenAPI schema. Cookie sessions are rejected for cross-origin requests.
DOCX export parses HTML with node-html-parser so it runs on the server,
and PDF line extraction moves to @reactive-resume/import for reuse.
2026-10-01 23:01:11 +02:00

146 lines
5.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
// 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,
{
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("keeps instance homepage resolution out of the public API", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(spec.paths).not.toHaveProperty("/resume/getRoot");
}, 15_000);
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" },
},
});
}, 15_000);
it("does not publish impossible request schemas", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
});
});
it("documents anonymous routes, all supported credentials, and additive PATCH routes", async () => {
const spec = await generateSpec();
expect(spec.paths?.["/flags"]?.get?.security).toEqual([]);
expect(spec.paths?.["/resumes/{username}/{slug}"]?.get?.security).toEqual([]);
expect(spec.paths?.["/resumes"]?.get?.security).toEqual([{ apiKey: [] }, { bearerAuth: [] }, { cookieAuth: [] }]);
for (const path of ["/applications/{id}", "/cover-letters/{id}"]) {
expect(spec.paths?.[path]?.put?.requestBody).toBeDefined();
expect(spec.paths?.[path]?.patch?.requestBody).toBeDefined();
}
expect(spec.paths?.["/files"]?.post?.requestBody).toHaveProperty("content.multipart/form-data");
expect(spec.paths?.["/files"]?.post?.requestBody).not.toHaveProperty("content.application/json");
expect(spec.paths?.["/resumes"]?.get?.parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "limit", in: "query" }),
expect.objectContaining({ name: "offset", in: "query" }),
]),
);
});
it("gives every published app operation an explicit route and input/output contracts", async () => {
const { openAPIRouter } = await import("./generator");
const visit = (node: unknown, path: string) => {
if (!node || typeof node !== "object") return;
if ("~orpc" in node) {
const definition = node["~orpc"] as {
route: { tags?: string[]; method?: string; path?: string };
inputSchema?: unknown;
outputSchema?: unknown;
};
if (definition.route.tags?.includes("Internal")) return;
expect(definition.route.method, path).toBeDefined();
expect(definition.route.path, path).toBeDefined();
expect(definition.inputSchema, path).toBeDefined();
expect(definition.outputSchema, path).toBeDefined();
return;
}
for (const [key, value] of Object.entries(node)) visit(value, `${path}.${key}`);
};
visit(openAPIRouter, "api");
});
it("describes empty responses and common errors without impossible payloads", async () => {
const spec = await generateSpec();
const deleted = spec.paths?.["/files"]?.delete?.responses?.["200"];
expect(deleted).toBeDefined();
expect(deleted).not.toHaveProperty("content");
expect(spec.paths?.["/resumes"]?.get?.responses?.default).toHaveProperty(
"content.application/json.schema.properties.code",
);
expect(spec.paths?.["/resumes"]?.get?.responses?.["200"]).toHaveProperty("headers.X-Total-Count");
});