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, note: {} as Record, delivery: {} as Record | null, events: [] as Record[], poItems: [] as Record[], pages: [] as Record[], auditFails: false, auditCollision: false, duplicateAtPostTime: null as Record | 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) => { 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" }); }); });