diff --git a/src/app/api/dn-batches/[id]/pages/[n]/route.test.ts b/src/app/api/dn-batches/[id]/pages/[n]/route.test.ts new file mode 100644 index 0000000..ed6c25b --- /dev/null +++ b/src/app/api/dn-batches/[id]/pages/[n]/route.test.ts @@ -0,0 +1,304 @@ +/** + * 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 with immutable cache headers, 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, splitPagesMock, fetchMock, viPbAdmin } = vi.hoisted( + () => ({ + dnBatchesGetOne: 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"; + +const ID = "abcdefghijklmno"; +const FILE = "scan.pdf"; + +function makeFakePb() { + return { + authStore: { token: "test-admin-token" }, + collection: (name: string) => { + if (name !== "dn_batches") { + throw new Error(`unexpected collection in test: ${name}`); + } + return { getOne: dnBatchesGetOne }; + }, + }; +} + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_POCKETBASE_URL", "http://pb.test"); + vi.stubEnv("POCKETBASE_ADMIN_EMAIL", "test@example.com"); + vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password"); + + viPbAdmin.mockReset(); + viPbAdmin.mockResolvedValue(makeFakePb() as never); + + dnBatchesGetOne.mockReset(); + splitPagesMock.mockReset(); + fetchMock.mockReset(); + + // Defaults: happy path. Individual tests override. + viPbAdmin.mockResolvedValue(makeFakePb() as never); + dnBatchesGetOne.mockResolvedValue({ id: ID, file: FILE }); + 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 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" }); + }); +}); + +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( + new Request(`http://x/api/dn-batches/${ID}/pages/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( + new Request(`http://x/api/dn-batches/${ID}/pages/1`), + { params: Promise.resolve({ id: ID, n: "1" }) }, + ); + expect(dnBatchesGetOne).toHaveBeenCalledWith(ID, { fields: "id,file" }); + }); + + it("returns 404 no_file when the batch has no attached file", async () => { + dnBatchesGetOne.mockResolvedValueOnce({ id: ID, file: "" }); + 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(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( + new Request(`http://x/api/dn-batches/${ID}/pages/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( + new Request(`http://x/api/dn-batches/${ID}/pages/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}`); + 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( + new Request(`http://x/api/dn-batches/${ID}/pages/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( + new Request(`http://x/api/dn-batches/${ID}/pages/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( + new Request(`http://x/api/dn-batches/${ID}/pages/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("returns the requested page's PNG with image/png and an immutable cache", async () => { + const res = await GET( + new Request(`http://x/api/dn-batches/${ID}/pages/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, max-age=86400, immutable", + ); + 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( + new Request(`http://x/api/dn-batches/${ID}/pages/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( + new Request(`http://x/api/dn-batches/${ID}/pages/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( + new Request(`http://x/api/dn-batches/${ID}/pages/1`), + { params: Promise.resolve({ id: ID, n: "1" }) }, + ); + // The route's first await is `await params`, so the body hasn't + // reached `pbAdmin()` yet by the time we get here. Yield once so + // params resolves, the route reaches pbAdmin, and the + // mockImplementationOnce executor runs and captures resolveAdmin. + await Promise.resolve(); + expect(dnBatchesGetOne).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(typeof resolveAdmin).toBe("function"); + resolveAdmin(makeFakePb()); + const res = await inflight; + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/dn-batches/[id]/pages/[n]/route.ts b/src/app/api/dn-batches/[id]/pages/[n]/route.ts new file mode 100644 index 0000000..0783377 --- /dev/null +++ b/src/app/api/dn-batches/[id]/pages/[n]/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import { splitPages } from "@/lib/dn-pipeline/split"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/dn-batches/[id]/pages/[n] + * + * One rendered page of a batch document as a PNG. Rendering on demand + * rather than storing every page keeps PocketBase's file store to one + * copy of the original plus one per-note PDF. + * + * Session-authenticated like the rest of the dashboard — the admin + * token never leaves the server. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string; n: string }> }, +) { + const { id, n } = await params; + if (!/^[a-z0-9]{15}$/i.test(id)) { + return NextResponse.json({ error: "invalid_batch_id" }, { status: 400 }); + } + const pageNo = Number(n); + if (!Number.isInteger(pageNo) || pageNo < 1) { + return NextResponse.json({ error: "invalid_page" }, { status: 400 }); + } + + const pb = await pbAdmin(); + let batch: { id: string; file: string }; + try { + batch = await pb.collection("dn_batches").getOne(id, { fields: "id,file" }); + } catch { + return NextResponse.json({ error: "batch_not_found" }, { status: 404 }); + } + if (!batch.file) { + return NextResponse.json({ error: "no_file" }, { status: 404 }); + } + + const base = process.env.NEXT_PUBLIC_POCKETBASE_URL; + if (!base) { + return NextResponse.json({ error: "pb_url_not_configured" }, { status: 500 }); + } + + const fileRes = await fetch(`${base}/api/files/dn_batches/${id}/${batch.file}`, { + headers: { Authorization: pb.authStore.token }, + }); + if (!fileRes.ok) { + return NextResponse.json({ error: "file_fetch_failed" }, { status: 502 }); + } + + const mime = fileRes.headers.get("content-type") ?? "application/pdf"; + const buffer = Buffer.from(await fileRes.arrayBuffer()); + + let pages; + try { + pages = await splitPages(buffer, mime); + } catch (err) { + console.error("[dn-page] render failed", err); + return NextResponse.json({ error: "render_failed" }, { status: 500 }); + } + + const hit = pages.find((p) => p.page === pageNo); + if (!hit) { + return NextResponse.json({ error: "page_out_of_range", pages: pages.length }, { status: 404 }); + } + + return new NextResponse(new Uint8Array(hit.png), { + status: 200, + headers: { + "Content-Type": "image/png", + // Pages of an uploaded document never change. + "Cache-Control": "private, max-age=86400, immutable", + }, + }); +}