MEGAINFRA-WEBAPP/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts
DEls 9a6aed50a4 feat(management): add admin planning dashboards
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.
2026-10-09 10:16:50 +02:00

251 lines
14 KiB
TypeScript

import "@/test-utils/named-admin-session";
import { beforeEach, describe, expect, it, vi } from "vitest";
const BATCH = "batch0000000001";
const NOTE = "note00000000001";
const SITE = "site00000000001";
const SUPPLIER = "supp00000000001";
const PO = "po0000000000001";
const ITEM = "poi000000000001";
const DELIVERY = "dn0000000000001";
const state = vi.hoisted(() => ({
batch: {} as Record<string, unknown>,
note: {} as Record<string, unknown>,
delivery: {} as Record<string, unknown> | null,
events: [] as Record<string, unknown>[],
poItems: [] as Record<string, unknown>[],
pages: [] as Record<string, unknown>[],
auditFails: false,
auditCollision: false,
duplicateAtPostTime: null as Record<string, unknown> | null,
}));
vi.mock("@/lib/pb", () => ({ pbAdmin: vi.fn(async () => ({
authStore: { token: "test" },
files: { getURL: (_record: unknown, filename: string) => `https://pb.test/${filename}`, getToken: vi.fn(async () => "file-token") },
collection(name: string) {
if (name === "dn_batches") return { getOne: vi.fn(async () => state.batch) };
if (name === "dn_batch_notes") return { getOne: vi.fn(async () => state.note) };
if (name === "suppliers") return { getOne: vi.fn(async () => ({ id: SUPPLIER, name: "Supplier" })) };
if (name === "purchase_orders") return { getOne: vi.fn(async () => ({ id: PO, site: SITE, supplier_ref: SUPPLIER })) };
if (name === "purchase_order_items") return { getFullList: vi.fn(async () => state.poItems) };
if (name === "dn_batch_pages") return { getFullList: vi.fn(async () => state.pages) };
if (name === "delivery_notes") return {
getOne: vi.fn(async () => state.delivery),
getFirstListItem: vi.fn(async () => {
if (state.duplicateAtPostTime) return state.duplicateAtPostTime;
throw new Error("no rows");
}),
};
if (name === "delivery_note_items") return { getFullList: vi.fn(async () => [{ id: "item-row" }]) };
if (name === "dn_review_events") return {
getFullList: vi.fn(async () => state.events),
create: vi.fn(async (event: Record<string, unknown>) => {
if (state.auditCollision) { state.events.push({ id: "winner", event_key: event.event_key, batch: BATCH, note: NOTE, action: "post", after: { delivery_note: DELIVERY } }); throw new Error("unique event key"); }
if (state.auditFails) throw new Error("audit unavailable"); state.events.push(event); return { id: "event" };
}),
};
throw new Error(`unexpected collection ${name}`);
},
})) }));
const promoteNote = vi.hoisted(() => vi.fn(async () => ({ deliveryNoteId: DELIVERY, itemsCreated: 1 })));
vi.mock("@/lib/dn-pipeline/promote", () => ({ promoteNote }));
function request() { return new Request("http://localhost", { method: "POST" }); }
function context() { return { params: Promise.resolve({ id: BATCH, noteId: NOTE }) }; }
beforeEach(() => {
state.batch = { id: BATCH, site: SITE, page_count: 1, assembly_revision: 3 };
state.note = {
id: NOTE, batch: BATCH, status: "candidate", stage: "ready_to_post", assembly_revision: 3,
pages: [1], doc_number: "DN-1", delivery_note_number: "DN-1", supplier: SUPPLIER, purchase_order: PO,
items: [{ description: "Aggregate", qty: 2, unit: "ton", purchase_order_item: ITEM }],
checks: ["po_resolved", "product_matched", "units_convertible", "not_duplicate", "qty_plausible"].map((id) => ({ id, passed: true })),
};
state.delivery = null;
state.events = [];
state.auditFails = false;
state.auditCollision = false;
state.duplicateAtPostTime = null;
state.poItems = [{ id: ITEM, purchase_order: PO, description: "Aggregate", unit: "ton", qty: 10, rate: 5 }];
state.pages = [{ id: "page00000000001", image: "page.png", mime: "image/png", source_page: 1, region: 0, expand: { source: { ordinal: 0 } } }];
promoteNote.mockClear();
const png = Uint8Array.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"));
vi.stubGlobal("fetch", vi.fn(async () => new Response(png, { status: 200 })));
});
describe("post delivery note route", () => {
it("blocks a ready candidate while its batch still needs grouping confirmation", async () => {
state.batch = { ...state.batch, stage: "split_review" };
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(409);
expect((await response.json()).reasons).toContain("page_grouping_not_complete");
expect(promoteNote).not.toHaveBeenCalled();
});
it("returns blocking reasons without promoting a needs-review candidate", async () => {
state.note = { ...state.note, stage: "needs_review", purchase_order: "", checks: [] };
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: "needs_review", reasons: ["candidate_not_ready_to_post"] });
expect(promoteNote).not.toHaveBeenCalled();
});
it("promotes a ready candidate and records one posting audit", async () => {
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ deliveryNoteId: DELIVERY, itemsCreated: 1, stage: "posted" });
expect(promoteNote).toHaveBeenCalledOnce();
expect(state.events).toHaveLength(1);
expect(state.events[0]).toMatchObject({ action: "post", note: NOTE });
});
it("uses a PocketBase file token to fetch a protected candidate page", async () => {
const png = Uint8Array.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"));
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) =>
String(input).includes("token=file-token")
? new Response(png, { status: 200 })
: new Response(null, { status: 404 })));
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://pb.test/page.png?token=file-token");
});
it("refuses to post when another delivery note with the same number was posted after this candidate's last rerun", async () => {
// Two scans of one physical delivery can each pass not_duplicate and
// reach ready_to_post before either is posted, because neither rerun
// saw the other on the ledger yet. Without a fresh check right here,
// both would post — the exact bug that let a real supplier get paid
// twice for one delivery.
state.duplicateAtPostTime = { id: "other-delivery-note", delivery_note_number: "DN-1" };
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: "needs_review", reasons: ["duplicate_at_post_time:other-delivery-note"] });
expect(promoteNote).not.toHaveBeenCalled();
});
it("reports an unlinked line as kind: none instead of a matched line with no PO item", async () => {
// A free accessory (rubber rings shipped alongside priced pipe) is
// deliberately left unlinked to any PO line. Reporting it as "matched"
// with no poItem crashed promoteNote, which trusts kind === "matched"
// to mean poItem is present.
state.note = {
...state.note,
items: [
{ description: "Pipe", qty: 11, unit: "ea", purchase_order_item: ITEM },
{ description: "Rubber rings", qty: 11, unit: "ea", purchase_order_item: "", unit_price: 0 },
],
checks: (state.note.checks as { id: string; passed: boolean }[]).map((check) =>
check.id === "product_matched"
? { ...check, detail: { freeAccessories: [{ lineIndex: 1 }] } }
: check),
};
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
const calls = promoteNote.mock.calls as unknown as { lineMatches: { match: { kind: string } }[] }[][];
expect(calls[0]![0]!.lineMatches.map((m) => m.match.kind)).toEqual(["matched", "none"]);
});
it("uses exactly the candidate's non-first canonical page", async () => {
state.batch = { ...state.batch, page_count: 2 };
state.note = { ...state.note, pages: [2], page_from: 2, page_to: 2 };
state.pages = [
{ id: "page00000000001", image: "first.png", mime: "image/png", source_page: 1, region: 0, expand: { source: { ordinal: 0 } } },
{ id: "page00000000002", image: "second.png", mime: "image/png", source_page: 2, region: 0, expand: { source: { ordinal: 0 } } },
];
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://pb.test/second.png?token=file-token");
});
it("selects an explicit global page after a missing middle page", async () => {
state.batch = { ...state.batch, page_count: 3 };
state.note = { ...state.note, pages: [3], page_from: 3, page_to: 3 };
state.pages = [
{ id: "page00000000001", image: "first.png", mime: "image/png", source_page: 1, global_page: 1, region: 0, expand: { source: { ordinal: 0 } } },
{ id: "page00000000003", image: "third.png", mime: "image/png", source_page: 3, global_page: 3, region: 0, expand: { source: { ordinal: 0 } } },
];
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://pb.test/third.png?token=file-token");
});
it("retries a real promotion instead of reporting success when a blocked candidate carries a dangling delivery_note pointer", async () => {
// promoteNote deliberately links a failed candidate to its
// partially-written delivery_notes row so the orphan stays visible —
// but the candidate's own status/stage stay "blocked"/"needs_review".
// Treating that pointer alone as proof of a finished posting skipped
// promoteNote on retry and left the ledger with a delivery note that
// has zero line items.
state.note = { ...state.note, status: "blocked", stage: "ready_to_post", delivery_note: DELIVERY };
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ deliveryNoteId: DELIVERY, itemsCreated: 1, stage: "posted" });
expect(promoteNote).toHaveBeenCalledOnce();
});
it("returns the same delivery note on an idempotent retry", async () => {
state.note = { ...state.note, status: "promoted", stage: "posted", delivery_note: DELIVERY };
state.delivery = { id: DELIVERY, batch_note: NOTE, batch: BATCH, site: SITE, purchase_order: PO };
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ deliveryNoteId: DELIVERY, itemsCreated: 1, stage: "posted" });
expect(promoteNote).not.toHaveBeenCalled();
});
it("reports a truthful posted result when audit persistence is temporarily unavailable", async () => {
state.auditFails = true;
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toMatchObject({ deliveryNoteId: DELIVERY, stage: "posted", auditPending: true });
expect(body).not.toHaveProperty("error", "promotion_failed");
});
it("confirms a concurrent deterministic audit winner after create collision", async () => {
state.auditCollision = true;
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ deliveryNoteId: DELIVERY, itemsCreated: 1, stage: "posted" });
});
it("does not accept a deterministic audit key owned by another note", async () => {
state.events = [{ id: "wrong", event_key: `dn-post:${NOTE}`, batch: BATCH, note: "note00000000002", action: "post" }];
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ stage: "posted", auditPending: true, auditError: "audit_key_collision" });
});
it("does not accept a same-note audit event pointing at another delivery note", async () => {
state.events = [{ id: "wrong-dn", event_key: `dn-post:${NOTE}`, batch: BATCH, note: NOTE, action: "post", after: { delivery_note: "dn0000000000002" } }];
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ stage: "posted", auditPending: true, auditError: "audit_key_collision" });
});
it("fails closed when a posted delivery note no longer matches its PO/site association", async () => {
state.note = { ...state.note, status: "promoted", stage: "posted", delivery_note: DELIVERY };
state.delivery = { id: DELIVERY, batch_note: NOTE, batch: BATCH, site: "site00000000002", purchase_order: PO };
const { POST } = await import("./route");
const response = await POST(request(), context());
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: "posted_delivery_note_mismatch" });
});
});