Add turnover, project, retention, and year comparison pages from the supplied workbook, protected by named admin access. Include the local chart gallery, grouped monthly hover, smooth comparison toggle, and monthly income minus cost series. Require named PocketBase sign-in in every environment and remove the shared PIN bypass. Update access documentation and business-route test fixtures.
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
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 <img> 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<string, string>) => 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);
|
|
});
|
|
});
|