diff --git a/src/pages/api/grade.test.ts b/src/pages/api/grade.test.ts new file mode 100644 index 0000000..9407351 --- /dev/null +++ b/src/pages/api/grade.test.ts @@ -0,0 +1,401 @@ +import handler from "./grade"; +import { exampleResponses } from "@/resume-checker/prompts/grade"; +import { Readable } from "node:stream"; +import type { IncomingHttpHeaders } from "node:http"; +import type { NextApiRequest, NextApiResponse } from "next"; +import pdf from "pdf-parse"; +import { generateObject } from "ai"; +import { google } from "@ai-sdk/google"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("pdf-parse", () => ({ + default: vi.fn(), +})); + +vi.mock("ai", () => ({ + generateObject: vi.fn(), +})); + +vi.mock("@ai-sdk/google", () => ({ + google: vi.fn(), +})); + +type RequestOptions = { + method?: string; + headers?: IncomingHttpHeaders; + query?: NextApiRequest["query"]; + body?: Buffer | string | Uint8Array; +}; + +function createRequest({ + method = "GET", + headers = {}, + query = {}, + body, +}: RequestOptions = {}) { + const stream = Readable.from( + body === undefined + ? [] + : [Buffer.isBuffer(body) ? body : Buffer.from(body)], + ); + + return Object.assign(stream, { + method, + headers, + query, + }) as NextApiRequest; +} + +function serializeBody(body: unknown) { + if (body === undefined || body === null) { + return ""; + } + + if (Buffer.isBuffer(body)) { + return body.toString("utf8"); + } + + if (typeof body === "string") { + return body; + } + + if (body instanceof Uint8Array) { + return Buffer.from(body).toString("utf8"); + } + + return JSON.stringify(body); +} + +function createResponse() { + const headers: Record = {}; + let bodyText = ""; + + const response = { + statusCode: 200, + setHeader(name: string, value: string | string[]) { + headers[name.toLowerCase()] = value; + return response; + }, + getHeader(name: string) { + return headers[name.toLowerCase()]; + }, + getHeaders() { + return { ...headers }; + }, + status(code: number) { + response.statusCode = code; + return response; + }, + json(body: unknown) { + response.setHeader("content-type", "application/json; charset=utf-8"); + bodyText = serializeBody(body); + return response; + }, + send(body: unknown) { + bodyText = serializeBody(body); + return response; + }, + end(body?: unknown) { + bodyText = serializeBody(body); + return response; + }, + } as unknown as NextApiResponse & { + getHeaders: () => Record; + }; + + return { + response, + get body() { + return bodyText; + }, + get headers() { + return response.getHeaders(); + }, + }; +} + +describe("/api/grade handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns example response for known public url", async () => { + const request = createRequest({ + method: "GET", + query: { url: "public/a_resume.pdf" }, + }); + const responseSpy = createResponse(); + + await handler(request, responseSpy.response); + + expect(responseSpy.response.statusCode).toBe(200); + expect(responseSpy.response.getHeader("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(responseSpy.body)).toEqual( + exampleResponses.get("public/a_resume.pdf"), + ); + expect(pdf).not.toHaveBeenCalled(); + expect(generateObject).not.toHaveBeenCalled(); + expect(google).not.toHaveBeenCalled(); + }); + + it("returns a mocked grading success response for uploaded resumes", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ + text: "parsed pdf", + info: { Author: "silver" }, + } as never); + vi.mocked(generateObject).mockResolvedValueOnce({ + object: { + grade: "B", + red_flags: ["Needs more impact"], + yellow_flags: ["Tighten the summary"], + }, + } as never); + + const request = createRequest({ + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=abc123", + }, + query: { url: "uploaded-resume.pdf" }, + body: "resume-bytes", + }); + const responseSpy = createResponse(); + + await handler(request, responseSpy.response); + + expect(responseSpy.response.statusCode).toBe(200); + expect(responseSpy.response.getHeader("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(responseSpy.body)).toEqual({ + grade: "B", + red_flags: ["Needs more impact"], + yellow_flags: ["Tighten the summary"], + }); + expect(pdf).toHaveBeenCalledTimes(1); + expect(generateObject).toHaveBeenCalledTimes(1); + expect(google).toHaveBeenCalledWith("gemini-2.5-flash"); + }); + + it("returns 405 json for unsupported methods", async () => { + const request = createRequest({ + method: "PUT", + }); + const responseSpy = createResponse(); + + await handler(request, responseSpy.response); + + expect(responseSpy.response.statusCode).toBe(405); + expect(responseSpy.response.getHeader("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(responseSpy.body)).toEqual({ + error: "Method not allowed", + }); + }); + + it("returns 400 json for missing or invalid get url", async () => { + const missingUrlRequest = createRequest({ + method: "GET", + }); + const missingUrlResponse = createResponse(); + + await handler(missingUrlRequest, missingUrlResponse.response); + + expect(missingUrlResponse.response.statusCode).toBe(400); + expect(JSON.parse(missingUrlResponse.body)).toEqual({ + error: "MissingURL", + }); + + const arrayUrlRequest = createRequest({ + method: "GET", + query: { url: ["one", "two"] }, + }); + const arrayUrlResponse = createResponse(); + + await handler(arrayUrlRequest, arrayUrlResponse.response); + + expect(arrayUrlResponse.response.statusCode).toBe(400); + expect(JSON.parse(arrayUrlResponse.body)).toEqual({ + error: "MissingURL", + }); + }); + + it("returns 400 json for non-multipart post requests", async () => { + const request = createRequest({ + method: "POST", + headers: { + "content-type": "application/json", + }, + query: { url: "public/a_resume.pdf" }, + }); + const responseSpy = createResponse(); + + await handler(request, responseSpy.response); + + expect(responseSpy.response.statusCode).toBe(400); + expect(responseSpy.response.getHeader("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(responseSpy.body)).toEqual({ + error: "InvalidUploadRequest", + }); + }); + + it("returns invalid pdf as 400 json", async () => { + vi.mocked(pdf).mockRejectedValueOnce( + new Error("InvalidPDFException: bad pdf"), + ); + + const request = createRequest({ + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=abc123", + }, + query: { url: "public/a_resume.pdf" }, + body: "resume-bytes", + }); + const responseSpy = createResponse(); + + await handler(request, responseSpy.response); + + expect(responseSpy.response.statusCode).toBe(400); + expect(responseSpy.response.getHeader("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(responseSpy.body)).toEqual({ + error: "InvalidPDFException", + }); + expect(generateObject).not.toHaveBeenCalled(); + }); + + it("returns unknown error as 500 json", async () => { + vi.mocked(pdf).mockRejectedValueOnce("not-an-error"); + + const request = createRequest({ + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=abc123", + }, + query: { url: "public/a_resume.pdf" }, + body: "resume-bytes", + }); + const responseSpy = createResponse(); + + await handler(request, responseSpy.response); + + expect(responseSpy.response.statusCode).toBe(500); + expect(responseSpy.response.getHeader("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(responseSpy.body)).toEqual({ + error: "UnknownError", + }); + expect(generateObject).not.toHaveBeenCalled(); + }); + + it("returns generic errors as 500 json", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ + text: "parsed pdf", + } as never); + vi.mocked(generateObject).mockRejectedValueOnce(new Error("GradingError")); + + const request = createRequest({ + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=abc123", + }, + query: { url: "public/a_resume.pdf" }, + body: "resume-bytes", + }); + const responseSpy = createResponse(); + + await handler(request, responseSpy.response); + + expect(responseSpy.response.statusCode).toBe(500); + expect(responseSpy.response.getHeader("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(responseSpy.body)).toEqual({ + error: "GradingError", + }); + expect(generateObject).toHaveBeenCalledTimes(1); + }); + + it("captures status headers and body from json responses", () => { + const jsonResponse = createResponse(); + + jsonResponse.response + .status(201) + .setHeader("x-trace-id", "json-1") + .json({ ok: true }); + + expect(jsonResponse.response.statusCode).toBe(201); + expect(jsonResponse.headers).toEqual( + expect.objectContaining({ + "content-type": "application/json; charset=utf-8", + "x-trace-id": "json-1", + }), + ); + expect(jsonResponse.body).toBe(JSON.stringify({ ok: true })); + + const sendResponse = createResponse(); + + sendResponse.response.status(202).setHeader("x-trace-id", "send-1").send({ + ok: "send", + }); + + expect(sendResponse.response.statusCode).toBe(202); + expect(sendResponse.headers).toEqual( + expect.objectContaining({ + "x-trace-id": "send-1", + }), + ); + expect(sendResponse.body).toBe(JSON.stringify({ ok: "send" })); + + const endResponse = createResponse(); + + endResponse.response + .status(203) + .setHeader("x-trace-id", "end-1") + .end(Buffer.from("done")); + + expect(endResponse.response.statusCode).toBe(203); + expect(endResponse.headers).toEqual( + expect.objectContaining({ + "x-trace-id": "end-1", + }), + ); + expect(endResponse.body).toBe("done"); + }); + + it("builds readable requests with method headers query and body", async () => { + const request = createRequest({ + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=abc123", + "x-request-id": "req-1", + }, + query: { url: "public/b_resume.pdf" }, + body: "resume-bytes", + }); + + const chunks: Buffer[] = []; + + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + expect(request.method).toBe("POST"); + expect(request.headers["content-type"]).toBe( + "multipart/form-data; boundary=abc123", + ); + expect(request.headers["x-request-id"]).toBe("req-1"); + expect(request.query).toEqual({ url: "public/b_resume.pdf" }); + expect(Buffer.concat(chunks).toString("utf8")).toBe("resume-bytes"); + }); +}); + +export default function GradeRouteTestHarness() {} diff --git a/src/pages/api/grade.ts b/src/pages/api/grade.ts index 8a6d125..1f72f77 100644 --- a/src/pages/api/grade.ts +++ b/src/pages/api/grade.ts @@ -17,26 +17,38 @@ function isMultipartFormData(req: NextApiRequest) { ); } +function isValidGetUrl(url: NextApiRequest["query"]["url"]): url is string { + return typeof url === "string"; +} + +function sendJsonError(res: NextApiResponse, status: number, error: string) { + return res.status(status).json({ error }); +} + export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { - try { - if (!["POST", "GET"].includes(req.method || "")) { - res.status(404).send({ error: "Not Found" }); - return; - } + if (!req.method || !["POST", "GET"].includes(req.method)) { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + if (req.method === "GET" && !isValidGetUrl(req.query.url)) { + res.status(400).json({ error: "MissingURL" }); + return; + } + if (req.method === "POST" && !isMultipartFormData(req)) { + res.status(400).json({ error: "InvalidUploadRequest" }); + return; + } + + try { let pdfBuffer: Buffer; - if (isMultipartFormData(req)) { - const chunks = []; - for await (const chunk of req) { - chunks.push(chunk); - } - pdfBuffer = Buffer.concat(chunks); - } else { - const { url } = req.query; - if (!url || typeof url !== "string") { + if (req.method === "GET") { + const url = req.query.url; + if (!isValidGetUrl(url)) { throw new Error("MissingURL"); } @@ -49,6 +61,14 @@ export default async function handler( const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); pdfBuffer = Buffer.from(arrayBuffer); + } else if (isMultipartFormData(req)) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + pdfBuffer = Buffer.concat(chunks); + } else { + throw new Error("InvalidUploadRequest"); } const parsed = await pdf(pdfBuffer); @@ -71,9 +91,7 @@ export default async function handler( } catch (e) { if (!(e instanceof Error)) { console.error(e); - res.status(500).send({ - error: "UnknownError", - }); + sendJsonError(res, 500, "UnknownError"); return; } @@ -82,16 +100,12 @@ export default async function handler( e.message.includes("Invalid PDF structure") ) { console.warn(e); - res.status(400).send({ - error: "InvalidPDFException", - }); + sendJsonError(res, 400, "InvalidPDFException"); return; } console.error(e); - res.status(500).send({ - error: e.message, - }); + sendJsonError(res, 500, e.message); } } diff --git a/src/resume-checker/pages/review.tsx b/src/resume-checker/pages/review.tsx index f0ef774..ec500e6 100644 --- a/src/resume-checker/pages/review.tsx +++ b/src/resume-checker/pages/review.tsx @@ -12,6 +12,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"; @@ -49,10 +50,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.test.ts b/src/resume-checker/utils.test.ts new file mode 100644 index 0000000..baad256 --- /dev/null +++ b/src/resume-checker/utils.test.ts @@ -0,0 +1,64 @@ +import { + DEFAULT_RESUME_CHECKER_ERROR, + getErrorMessage, + RESUME_TOO_LARGE_ERROR, +} from "./utils"; +import { describe, expect, it } from "vitest"; + +describe("getErrorMessage", () => { + it("returns the json error field when present", async () => { + const response = new Response( + JSON.stringify({ error: "InvalidPDFException" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + + await expect(getErrorMessage(response)).resolves.toBe( + "InvalidPDFException", + ); + }); + + it("returns the json message field when present", async () => { + const response = new Response( + JSON.stringify({ message: "File is too large" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + + await expect(getErrorMessage(response)).resolves.toBe("File is too large"); + }); + + it("maps plain text 413 responses to a stable message", async () => { + const response = new Response("Request Entity Too Large", { + status: 413, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + + await expect(getErrorMessage(response)).resolves.toBe( + RESUME_TOO_LARGE_ERROR, + ); + }); + + it("falls back to the text body for non-json errors", async () => { + const response = new Response("Internal Server Error", { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + + await expect(getErrorMessage(response)).resolves.toBe( + "Internal Server Error", + ); + }); + + it("uses the default message when the response body is empty", async () => { + const response = new Response(null, { status: 500 }); + + await expect(getErrorMessage(response)).resolves.toBe( + DEFAULT_RESUME_CHECKER_ERROR, + ); + }); +}); diff --git a/src/resume-checker/utils.ts b/src/resume-checker/utils.ts index 0f7e4a4..976bcad 100644 --- a/src/resume-checker/utils.ts +++ b/src/resume-checker/utils.ts @@ -1 +1,47 @@ -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_RESUME_CHECKER_ERROR = "Hubo un error inesperado"; +export const RESUME_TOO_LARGE_ERROR = + "El PDF es demasiado grande. Probá con un archivo más chico."; + +type ErrorPayload = { + error?: string; + message?: string; +}; + +function getErrorPayloadMessage(payload: unknown) { + if (!payload || typeof payload !== "object") { + return null; + } + + const { error, message } = payload as ErrorPayload; + + if (typeof error === "string" && error.length > 0) { + return error; + } + + if (typeof message === "string" && message.length > 0) { + return message; + } + + return null; +} + +export async function getErrorMessage(response: Response) { + if (response.status === 413) { + return RESUME_TOO_LARGE_ERROR; + } + + const text = await response.text(); + + if (!text) { + return DEFAULT_RESUME_CHECKER_ERROR; + } + + try { + return getErrorPayloadMessage(JSON.parse(text)) || text; + } catch { + return text; + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2095657 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + environment: "node", + }, + resolve: { + alias: { + "@": resolve(root, "src"), + }, + }, +});