import "@/test-utils/named-admin-session"; /** * Tests for GET /api/dn-batches/[id]/pages/[n]. * * The page image route is the only read in the inbox pipeline that * has to live in Next.js at all — a browser tag cannot carry the * admin token. It mirrors the proxy pattern in * src/app/api/delivery-notes/[id]/slip/route.ts. * * What the route does: * 1. Validates path params: id is a 15-char alnum PocketBase id, n is * a positive integer. * 2. Looks the batch up in dn_batches; needs `file` to be set. * 3. Fetches the file from PB's /api/files endpoint with the admin * token's Authorization header. * 4. Renders the requested page with splitPages (mocked here). * 5. Returns the PNG without browser caching, or an error JSON * with a precise code. * * PocketBase, the split renderer, and the global fetch are all mocked * so the route is exercised in isolation. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /* ------------------------------------------------------------------ */ /* Hoisted mocks */ /* ------------------------------------------------------------------ */ const { dnBatchesGetOne, dnPagesGetFullList, filesGetURL, splitPagesMock, fetchMock, viPbAdmin } = vi.hoisted( () => ({ dnBatchesGetOne: vi.fn(), dnPagesGetFullList: vi.fn(), filesGetURL: vi.fn(), splitPagesMock: vi.fn(), fetchMock: vi.fn(), viPbAdmin: vi.fn(), }), ); vi.mock("@/lib/pb", () => ({ pbAdmin: viPbAdmin, })); vi.mock("@/lib/dn-pipeline/split", () => ({ splitPages: splitPagesMock, })); import { pbAdmin } from "@/lib/pb"; import { GET } from "./route"; import { signDnPageProxyToken } from "@/lib/dn-page-token"; const ID = "abcdefghijklmno"; const FILE = "scan.pdf"; function makeFakePb() { return { authStore: { token: "test-admin-token" }, filter: (expression: string, values: Record) => expression.replace("{:batch}", `"${values.batch}"`), collection: (name: string) => { if (name === "dn_batches") return { getOne: dnBatchesGetOne }; if (name === "dn_batch_pages") return { getFullList: dnPagesGetFullList }; throw new Error(`unexpected collection in test: ${name}`); }, files: { getURL: filesGetURL, getToken: vi.fn(async () => "file-token") }, }; } function pageRequest(page: string, batch = ID) { const token = signDnPageProxyToken(batch, Number(page)); return new Request(`http://x/api/dn-batches/${batch}/pages/${page}?token=${token}`); } beforeEach(() => { vi.stubEnv("NEXT_PUBLIC_POCKETBASE_URL", "http://pb.test"); vi.stubEnv("DN_PAGE_PROXY_SECRET", "test-page-secret"); vi.stubEnv("POCKETBASE_ADMIN_EMAIL", "test@example.com"); vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password"); viPbAdmin.mockReset(); viPbAdmin.mockResolvedValue(makeFakePb() as never); dnBatchesGetOne.mockReset(); dnPagesGetFullList.mockReset(); filesGetURL.mockReset(); splitPagesMock.mockReset(); fetchMock.mockReset(); // Defaults: happy path. Individual tests override. viPbAdmin.mockResolvedValue(makeFakePb() as never); dnBatchesGetOne.mockResolvedValue({ id: ID, file: FILE }); dnPagesGetFullList.mockResolvedValue([]); fetchMock.mockResolvedValue( new Response(Buffer.from("%PDF-mock"), { status: 200, headers: { "content-type": "application/pdf" }, }), ); splitPagesMock.mockResolvedValue([ { page: 1, png: Buffer.from([0x89, 0x50, 0x4e, 0x47, 1]) }, { page: 2, png: Buffer.from([0x89, 0x50, 0x4e, 0x47, 2]) }, { page: 3, png: Buffer.from([0x89, 0x50, 0x4e, 0x47, 3]) }, ]); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe("GET /api/dn-batches/[id]/pages/[n] — path validation", () => { it("returns 401 without a valid signed proxy token", async () => { const res = await GET(new Request(`http://x/api/dn-batches/${ID}/pages/1`), { params: Promise.resolve({ id: ID, n: "1" }) }); expect(res.status).toBe(401); expect(dnBatchesGetOne).not.toHaveBeenCalled(); }); it("fails closed when the signing secret is not configured", async () => { vi.stubEnv("DN_PAGE_PROXY_SECRET", ""); vi.stubEnv("N8N_INGEST_SECRET", ""); const res = await GET(pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }); expect(res.status).toBe(401); expect(dnBatchesGetOne).not.toHaveBeenCalled(); }); it("returns 400 invalid_batch_id when the id is malformed", async () => { const res = await GET( new Request("http://x/api/dn-batches/shortid/pages/1"), { params: Promise.resolve({ id: "shortid", n: "1" }) }, ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "invalid_batch_id" }); expect(dnBatchesGetOne).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); }); it("returns 400 invalid_page when n is not a positive integer", async () => { for (const bad of ["0", "-1", "abc", "1.5"]) { const res = await GET( new Request(`http://x/api/dn-batches/${ID}/pages/${bad}`), { params: Promise.resolve({ id: ID, n: bad }) }, ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "invalid_page" }); } expect(dnBatchesGetOne).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); }); it("returns 400 invalid_page when n is the empty string", async () => { const res = await GET( new Request(`http://x/api/dn-batches/${ID}/pages/`), { params: Promise.resolve({ id: ID, n: "" }) }, ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "invalid_page" }); }); it("rejects a page above the positive persisted batch page count before staged lookup", async () => { dnBatchesGetOne.mockResolvedValueOnce({ id: ID, file: FILE, page_count: 2 }); const res = await GET(pageRequest("3"), { params: Promise.resolve({ id: ID, n: "3" }) }); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "page_out_of_range", pages: 2 }); expect(dnPagesGetFullList).not.toHaveBeenCalled(); }); }); describe("GET /api/dn-batches/[id]/pages/[n] — batch lookup", () => { it("returns 404 batch_not_found when the batch row does not exist", async () => { dnBatchesGetOne.mockRejectedValueOnce( Object.assign(new Error("missing"), { status: 404 }), ); const res = await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "batch_not_found" }); expect(fetchMock).not.toHaveBeenCalled(); }); it("only requests the id and file fields", async () => { await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(dnBatchesGetOne).toHaveBeenCalledWith(ID, { fields: "id,file,page_count,site" }); }); it("returns 404 no_file when the batch has no attached file", async () => { dnBatchesGetOne.mockResolvedValueOnce({ id: ID, file: "" }); const res = await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "no_file" }); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("GET /api/dn-batches/[id]/pages/[n] — env", () => { it("returns 500 pb_url_not_configured when NEXT_PUBLIC_POCKETBASE_URL is unset", async () => { vi.stubEnv("NEXT_PUBLIC_POCKETBASE_URL", ""); const res = await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(res.status).toBe(500); expect(await res.json()).toEqual({ error: "pb_url_not_configured" }); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("GET /api/dn-batches/[id]/pages/[n] — file fetch", () => { it("fetches the file from PB with the admin Authorization header", async () => { await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(`http://pb.test/api/files/dn_batches/${ID}/${FILE}?token=file-token`); expect(init).toMatchObject({ headers: { Authorization: "test-admin-token" }, }); }); it("returns 502 file_fetch_failed when PB returns a non-OK status", async () => { fetchMock.mockResolvedValueOnce(new Response("nope", { status: 503 })); const res = await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(res.status).toBe(502); expect(await res.json()).toEqual({ error: "file_fetch_failed" }); expect(splitPagesMock).not.toHaveBeenCalled(); }); }); describe("GET /api/dn-batches/[id]/pages/[n] — render", () => { it("returns 500 render_failed when splitPages throws", async () => { splitPagesMock.mockRejectedValueOnce(new Error("pdftoppm exploded")); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const res = await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(res.status).toBe(500); expect(await res.json()).toEqual({ error: "render_failed" }); expect(errSpy).toHaveBeenCalled(); errSpy.mockRestore(); }); it("returns 404 page_out_of_range with the rendered page count when the page is missing", async () => { const res = await GET( pageRequest("9"), { params: Promise.resolve({ id: ID, n: "9" }) }, ); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "page_out_of_range", pages: 3 }); }); }); describe("GET /api/dn-batches/[id]/pages/[n] — happy path", () => { it("serves a persisted canonical page image when staged metadata is available", async () => { dnPagesGetFullList.mockResolvedValueOnce([ { id: "page-1", batch: ID, source: "source-1", source_page: 1, region: 0, image: "page.png", mime: "image/png", expand: { source: { ordinal: 0 } } }, ]); filesGetURL.mockReturnValueOnce("http://pb.test/api/files/dn_batch_pages/page-1/page.png"); fetchMock.mockResolvedValueOnce(new Response(Buffer.from([137, 80, 78, 71]), { status: 200, headers: { "content-type": "image/png" } })); const res = await GET(pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("image/png"); expect(filesGetURL).toHaveBeenCalledWith(expect.objectContaining({ id: "page-1", collectionName: "dn_batch_pages" }), "page.png", { token: "file-token" }); expect(splitPagesMock).not.toHaveBeenCalled(); }); it("returns the requested page's PNG with image/png and no browser cache", async () => { const res = await GET( pageRequest("2"), { params: Promise.resolve({ id: ID, n: "2" }) }, ); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("image/png"); expect(res.headers.get("Cache-Control")).toBe( "private, no-store", ); const body = new Uint8Array(await res.arrayBuffer()); // page 2 in the default mock fixture ends with the byte 0x02. expect(body[body.length - 1]).toBe(2); }); it("passes the file bytes and content-type to splitPages", async () => { fetchMock.mockResolvedValueOnce( new Response(Buffer.from("PDF-BYTES"), { status: 200, headers: { "content-type": "application/pdf" }, }), ); await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(splitPagesMock).toHaveBeenCalledTimes(1); const [buf, mime] = splitPagesMock.mock.calls[0]; expect(Buffer.isBuffer(buf) || buf instanceof Uint8Array).toBe(true); expect(mime).toBe("application/pdf"); }); it("defaults to application/pdf when PB omits the content-type header", async () => { fetchMock.mockResolvedValueOnce( new Response(Buffer.from("PDF-BYTES"), { status: 200 }), ); await GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); expect(splitPagesMock.mock.calls[0][1]).toBe("application/pdf"); }); }); describe("GET /api/dn-batches/[id]/pages/[n] — admin auth", () => { it("awaits pbAdmin before doing any PocketBase calls", async () => { let resolveAdmin!: (pb: unknown) => void; viPbAdmin.mockImplementationOnce( () => new Promise((resolve) => { resolveAdmin = resolve; }), ); const inflight = GET( pageRequest("1"), { params: Promise.resolve({ id: ID, n: "1" }) }, ); // Session validation and params both complete before pbAdmin starts. await vi.waitFor(() => expect(typeof resolveAdmin).toBe("function")); expect(dnBatchesGetOne).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); resolveAdmin(makeFakePb()); const res = await inflight; expect(res.status).toBe(200); }); });