MEGAINFRA-WEBAPP/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts

252 lines
14 KiB
TypeScript
Raw Normal View History

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" });
});
});