From f146e42ed066655861a285c5f26b8c4c3381a2fb Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 9 Jul 2026 22:23:49 +0200 Subject: [PATCH] test(po-pdf): cover token capture + proxy routing --- .../v1/purchase-orders/[id]/pdf/route.test.ts | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/app/api/v1/purchase-orders/[id]/pdf/route.test.ts diff --git a/src/app/api/v1/purchase-orders/[id]/pdf/route.test.ts b/src/app/api/v1/purchase-orders/[id]/pdf/route.test.ts new file mode 100644 index 0000000..5125682 --- /dev/null +++ b/src/app/api/v1/purchase-orders/[id]/pdf/route.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +// Mocks +const getOneMock = vi.fn(); +const updateMock = vi.fn(); +const fetchMock = vi.fn(); + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: (name: string) => { + if (name === "order_items") { + return { + getFullList: vi + .fn() + .mockResolvedValue([ + { id: "item-1", quantity: 1, cost_price: 100, purchase_order: "po-1" }, + ]), + }; + } + return { + getOne: getOneMock, + update: updateMock, + }; + }, + files: { getURL: vi.fn() }, + authStore: { token: "test-token" }, + }), +})); + +vi.mock("@/lib/letterhead", () => ({ + fetchLetterhead: vi.fn().mockResolvedValue({ id: "cnl", name: "CNL" }), + loadLetterheadLogoDataUri: vi.fn().mockResolvedValue("data:image/svg+xml;base64,AAA"), +})); + +vi.mock("@/lib/pdf/render", () => ({ + renderPurchaseOrderPdf: vi.fn().mockResolvedValue(Buffer.from("PDF")), +})); + +import { GET, POST } from "./route"; + +function makeReq(url: string, init: { method?: string; body?: unknown } = {}) { + return new NextRequest(url, { + method: init.method ?? "GET", + headers: { cookie: "test-cookie" }, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); +} + +describe("/api/v1/purchase-orders/[id]/pdf", () => { + beforeEach(() => { + getOneMock.mockReset(); + updateMock.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("POST — token capture", () => { + it("stores the PB token, not the human-readable filename, in documents_history", async () => { + updateMock + .mockResolvedValueOnce({ + documents: ["slug-token-v3.pdf", "slug-token-v4.pdf"], + }) + .mockResolvedValueOnce({}); + + getOneMock.mockResolvedValueOnce({ + id: "po-1", + po_number: "PO-2026-07-009", + order: "order-1", + supplier: "supplier-1", + expand: { order: { id: "order-1" }, supplier: { id: "supplier-1" } }, + documents: ["slug-token-v3.pdf"], + documents_history: [ + { + version: 3, + file: "PO-CNL-PO-2026-07-009-v3.pdf", + token: "slug-token-v3.pdf", + generatedAt: "2026-07-09T00:00:00Z", + generatedBy: "", + reason: "", + status: "current", + }, + ], + }); + + const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf", { + method: "POST", + body: {}, + }); + + const res = await POST(req, { params: Promise.resolve({ id: "po-1" }) }); + expect(res.status).toBe(200); + + const historyCall = updateMock.mock.calls[1]; + expect(historyCall[1]).toEqual({ + documents_history: expect.arrayContaining([ + expect.objectContaining({ + version: 4, + file: "PO-CNL-PO-2026-07-009-v4.pdf", + token: "slug-token-v4.pdf", + status: "current", + }), + ]), + }); + }); + }); + + describe("GET — proxy routing", () => { + it("streams the PDF through the file proxy using entry.token", async () => { + getOneMock.mockResolvedValueOnce({ + id: "po-1", + po_number: "PO-2026-07-009", + documents: ["slug-v4.pdf"], + documents_history: [ + { + version: 4, + file: "PO-CNL-PO-2026-07-009-v4.pdf", + token: "slug-v4.pdf", + generatedAt: "2026-07-09T00:00:00Z", + generatedBy: "", + reason: "", + status: "current", + }, + ], + }); + + fetchMock.mockResolvedValueOnce( + new Response(Buffer.from("PDF bytes"), { + status: 200, + headers: { "Content-Type": "application/pdf" }, + }), + ); + + const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf?version=4"); + const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) }); + + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/v1/files/purchase_orders/po-1/slug-v4.pdf"); + }); + + it("falls back to documents[i] when entry.token is missing", async () => { + getOneMock.mockResolvedValueOnce({ + id: "po-1", + po_number: "PO-2026-07-009", + documents: ["legacy-token.pdf"], + documents_history: [ + { + version: 2, + file: "PO-CNL-PO-2026-07-009-v2.pdf", + generatedAt: "2026-07-09T00:00:00Z", + generatedBy: "", + reason: "", + status: "superseded", + }, + ], + }); + + fetchMock.mockResolvedValueOnce( + new Response(Buffer.from("PDF bytes"), { status: 200 }), + ); + + const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf?version=2"); + const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) }); + + expect(res.status).toBe(200); + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/v1/files/purchase_orders/po-1/legacy-token.pdf"); + }); + + it("404s with a clear message when no token is resolvable", async () => { + getOneMock.mockResolvedValueOnce({ + id: "po-1", + po_number: "PO-2026-07-009", + documents: [], + documents_history: [ + { + version: 1, + file: "PO-CNL-PO-2026-07-009-v1.pdf", + generatedAt: "2026-07-09T00:00:00Z", + generatedBy: "", + reason: "", + status: "current", + }, + ], + }); + + const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf"); + const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toMatch(/token missing/i); + }); + + it("respects ?version=N and ?download=1", async () => { + getOneMock.mockResolvedValueOnce({ + id: "po-1", + po_number: "PO-2026-07-009", + documents: ["slug-v2.pdf"], + documents_history: [ + { + version: 1, + file: "PO-CNL-PO-2026-07-009-v1.pdf", + token: "slug-v1.pdf", + generatedAt: "2026-07-09T00:00:00Z", + generatedBy: "", + reason: "", + status: "superseded", + }, + { + version: 2, + file: "PO-CNL-PO-2026-07-009-v2.pdf", + token: "slug-v2.pdf", + generatedAt: "2026-07-09T00:00:00Z", + generatedBy: "", + reason: "", + status: "current", + }, + ], + }); + + fetchMock.mockResolvedValueOnce( + new Response(Buffer.from("PDF"), { status: 200 }), + ); + + const req = makeReq( + "http://localhost/api/v1/purchase-orders/po-1/pdf?version=1&download=1", + ); + const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) }); + + expect(res.status).toBe(200); + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/v1/files/purchase_orders/po-1/slug-v1.pdf"); + expect(res.headers.get("Content-Disposition")).toMatch(/^attachment/); + }); + }); +}); \ No newline at end of file