diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a7a84d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 4bd17dc..0e9d7ef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/resume-checker/pages/review.tsx b/src/resume-checker/pages/review.tsx index 65d61e1..a02a519 100644 --- a/src/resume-checker/pages/review.tsx +++ b/src/resume-checker/pages/review.tsx @@ -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"; @@ -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(); diff --git a/src/resume-checker/utils.ts b/src/resume-checker/utils.ts index 0f7e4a4..d46d577 100644 --- a/src/resume-checker/utils.ts +++ b/src/resume-checker/utils.ts @@ -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 = { + 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; +} diff --git a/tests/api/grade.test.ts b/tests/api/grade.test.ts new file mode 100644 index 0000000..bc640db --- /dev/null +++ b/tests/api/grade.test.ts @@ -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" }); + }); +}); diff --git a/tests/resume-checker/utils.test.ts b/tests/resume-checker/utils.test.ts new file mode 100644 index 0000000..e3752b2 --- /dev/null +++ b/tests/resume-checker/utils.test.ts @@ -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, "Gateway Timeout")), + ).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, + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9bd68c4 --- /dev/null +++ b/vitest.config.ts @@ -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)), + }, + }, +});