Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- run: bun install --frozen-lockfile

- run: bun run lint

- run: bunx tsc --noEmit

- run: bun run test
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"eval:dev": "evalite watch",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@ai-sdk/google": "^2.0.86",
Expand Down
6 changes: 2 additions & 4 deletions src/resume-checker/pages/review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Score } from "@/resume-checker/components/score";
import { Skeleton } from "@/resume-checker/components/skeleton";
import { useFormState } from "@/resume-checker/hooks/form-context";
import type { FormState } from "@/resume-checker/types";
import { getErrorMessage } from "@/resume-checker/utils";
import { sendGAEvent } from "@next/third-parties/google";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
Expand Down Expand Up @@ -51,10 +52,7 @@ export function Review() {
}

if (!res.ok) {
const err = await res.json();
throw new Error(
"error" in err ? err.error : "Hubo un error inesperado",
);
throw new Error(await getErrorMessage(res));
}

return res.json();
Expand Down
42 changes: 41 additions & 1 deletion src/resume-checker/utils.ts
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
export const TYPST_TEMPLATE_URL = "https://typst.app/universe/package/silver-dev-cv";
export const TYPST_TEMPLATE_URL =
"https://typst.app/universe/package/silver-dev-cv";

export const DEFAULT_ERROR_MESSAGE = "Hubo un error inesperado";

/* Vercel rejects function payloads over 4.5MB itself, before the route runs. */
export const PAYLOAD_TOO_LARGE_MESSAGE =
"El PDF es demasiado grande. Probá con uno de menos de 4.5 MB.";

/* Keys are the error codes /api/grade answers with. */
const ERROR_MESSAGES: Record<string, string> = {
InvalidPDFException: "No pudimos leer el PDF. Probá con otro archivo.",
InvalidUploadRequest: "No pudimos leer el archivo que subiste.",
MissingURL: "No encontramos el CV que querías analizar.",
};

async function readErrorCode(response: Response) {
try {
const payload = await response.json();
const code = payload?.error;
return typeof code === "string" ? code : null;
} catch {
/* 413s, gateway timeouts and edge error pages are not JSON. */
return null;
}
}

/**
* Turn a failed /api/grade response into copy worth showing in the error badge.
* Nothing from the body reaches the user: the route answers with stable codes,
* and platform failures (413, gateway timeouts) are not JSON at all.
*/
export async function getErrorMessage(response: Response) {
if (response.status === 413) {
return PAYLOAD_TOO_LARGE_MESSAGE;
}

const code = await readErrorCode(response);

return (code && ERROR_MESSAGES[code]) || DEFAULT_ERROR_MESSAGE;
}
168 changes: 168 additions & 0 deletions tests/api/grade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import handler from "@/pages/api/grade";
import { exampleResponses } from "@/resume-checker/prompts/grade";
import { generateObject } from "ai";
import type { NextApiRequest, NextApiResponse } from "next";
import type { IncomingHttpHeaders } from "node:http";
import { Readable } from "node:stream";
import pdf from "pdf-parse";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("pdf-parse", () => ({ default: vi.fn() }));
vi.mock("ai", () => ({ generateObject: vi.fn() }));

const MULTIPART = { "content-type": "multipart/form-data; boundary=abc" };

function request({
method = "GET",
headers = {},
query = {},
body = "",
}: {
method?: string;
headers?: IncomingHttpHeaders;
query?: NextApiRequest["query"];
body?: string;
} = {}) {
return Object.assign(Readable.from([Buffer.from(body)]), {
method,
headers,
query,
}) as unknown as NextApiRequest;
}

function response() {
const spy = {
status: 200,
body: undefined as unknown,
json(payload: unknown) {
spy.body = payload;
return spy;
},
};

return Object.assign(spy, {
status(code: number) {
spy.status = code;
return spy;
},
}) as unknown as NextApiResponse & { status: number; body: unknown };
}

async function call(req: NextApiRequest) {
const res = response();
await handler(req, res);
return res as unknown as { status: number; body: unknown };
}

describe("/api/grade", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("serves the canned grade for example resumes without calling the model", async () => {
const res = await call(request({ query: { url: "public/a_resume.pdf" } }));

expect(res.status).toBe(200);
expect(res.body).toEqual(exampleResponses.get("public/a_resume.pdf"));
expect(pdf).not.toHaveBeenCalled();
expect(generateObject).not.toHaveBeenCalled();
});

it("grades an uploaded resume", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockResolvedValueOnce({
object: { grade: "B", red_flags: ["flag"], yellow_flags: [] },
} as never);

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

expect(res.status).toBe(200);
expect(res.body).toEqual({
grade: "B",
red_flags: ["flag"],
yellow_flags: [],
});
expect(vi.mocked(pdf).mock.calls[0][0]).toEqual(Buffer.from("pdf-bytes"));
});

/* Crawlers submit the resume-checker form as a GET, so these must not be 500s. */
it.each([
["no url", {}],
["a blank url", { url: " " }],
["only the honeypot field", { name: "2026" }],
["a repeated url", { url: ["a", "b"] }],
])("answers 400 to a GET with %s", async (_label, query) => {
const res = await call(request({ query }));

expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "MissingURL" });
});

it("answers 405 to unsupported methods", async () => {
const res = await call(request({ method: "PUT" }));

expect(res.status).toBe(405);
expect(res.body).toEqual({ error: "MethodNotAllowed" });
});

it("answers 400 to a POST that is not a file upload", async () => {
const res = await call(
request({
method: "POST",
headers: { "content-type": "application/json" },
}),
);

expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "InvalidUploadRequest" });
expect(pdf).not.toHaveBeenCalled();
});

it("answers 400 to an unreadable PDF", async () => {
vi.mocked(pdf).mockRejectedValueOnce(
new Error("InvalidPDFException: nope"),
);

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "not-a-pdf" }),
);

expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "InvalidPDFException" });
expect(generateObject).not.toHaveBeenCalled();
});

/* The client renders this straight into a badge, so it must stay a code. */
it("hides the underlying message when grading fails", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockRejectedValueOnce(
new Error("A positive credit balance is required"),
);

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "GradingError" });
});

it("answers 500 to a thrown non-Error", async () => {
vi.mocked(pdf).mockRejectedValueOnce("boom");

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "UnknownError" });
});
});
62 changes: 62 additions & 0 deletions tests/resume-checker/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
DEFAULT_ERROR_MESSAGE,
getErrorMessage,
PAYLOAD_TOO_LARGE_MESSAGE,
} from "@/resume-checker/utils";
import { describe, expect, it } from "vitest";

function json(status: number, payload: unknown) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}

function text(status: number, body: string) {
return new Response(body, {
status,
headers: { "content-type": "text/plain" },
});
}

describe("getErrorMessage", () => {
it("translates the error codes the route answers with", async () => {
await expect(
getErrorMessage(json(400, { error: "InvalidPDFException" })),
).resolves.toBe("No pudimos leer el PDF. Probá con otro archivo.");
await expect(
getErrorMessage(json(400, { error: "InvalidUploadRequest" })),
).resolves.toBe("No pudimos leer el archivo que subiste.");
await expect(
getErrorMessage(json(400, { error: "MissingURL" })),
).resolves.toBe("No encontramos el CV que querías analizar.");
});

/* Vercel rejects oversized uploads itself, with a plain-text body. */
it("explains a payload rejected before the route ran", async () => {
await expect(
getErrorMessage(text(413, "Request Entity Too Large")),
).resolves.toBe(PAYLOAD_TOO_LARGE_MESSAGE);
});

it("falls back to the default message instead of throwing on non-JSON", async () => {
await expect(
getErrorMessage(text(504, "<html>Gateway Timeout</html>")),
).resolves.toBe(DEFAULT_ERROR_MESSAGE);
await expect(
getErrorMessage(new Response(null, { status: 500 })),
).resolves.toBe(DEFAULT_ERROR_MESSAGE);
});

it("never renders an unrecognized code or payload shape", async () => {
await expect(
getErrorMessage(json(500, { error: "GradingError" })),
).resolves.toBe(DEFAULT_ERROR_MESSAGE);
await expect(
getErrorMessage(json(500, { error: { code: 7 } })),
).resolves.toBe(DEFAULT_ERROR_MESSAGE);
await expect(getErrorMessage(json(500, {}))).resolves.toBe(
DEFAULT_ERROR_MESSAGE,
);
});
});
14 changes: 14 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});
Loading