/** * Integration tests for the confirmReceipt server action. * Covers: full receipt, partial receipt, upsert notes on repeated confirmation, * and permission guards. */ import { vi, describe, it, expect, beforeAll, afterEach } from "vitest"; vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("@/lib/notifier", () => ({ notify: vi.fn() })); import { auth } from "@/auth"; import { db } from "@/lib/db"; import { createPo } from "@/app/(portal)/po/new/actions"; import { approvePo } from "@/app/(portal)/approvals/[id]/actions"; import { processPayment, markPaid } from "@/app/(portal)/payments/actions"; import { confirmReceipt } from "@/app/(portal)/po/[id]/receipt/actions"; import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle, } from "./helpers"; const PREFIX = "INTTEST_RECEIPT_"; const TODAY = new Date().toISOString().slice(0, 10); let techId: string; let managerId: string; let accountsId: string; let vesselId: string; let accountId: string; beforeAll(async () => { const [tech, mgr, acct, vessel, account] = await Promise.all([ getSeedUser("tech@pelagia.local"), getSeedUser("manager@pelagia.local"), getSeedUser("accounts@pelagia.local"), getSeedVessel("MV Sea Breeze"), getSeedAccount("700202"), ]); techId = tech.id; managerId = mgr.id; accountsId = acct.id; vesselId = vessel.id; accountId = account.id; }); afterEach(async () => { await deletePosByTitle(PREFIX); }); /** Create a PO and drive it to PAID_DELIVERED (fully paid). */ async function createPaidPo(title: string): Promise { vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(techId, "TECHNICAL")); const form = makePoForm({ title, vesselId, accountId, intent: "submit" }); const { id: poId } = (await createPo(form)) as { id: string }; vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER")); await approvePo({ poId }); vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); await processPayment({ poId }); await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY }); vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(techId, "TECHNICAL")); return poId; } describe("confirmReceipt — full delivery", () => { it("transitions PAID_DELIVERED to CLOSED when all items delivered", async () => { const poId = await createPaidPo(`${PREFIX}Full`); const result = await confirmReceipt({ poId }); expect(result).toEqual({ ok: true, partial: false }); const po = await db.purchaseOrder.findUnique({ where: { id: poId } }); expect(po?.status).toBe("CLOSED"); expect(po?.closedAt).not.toBeNull(); }); it("records RECEIPT_CONFIRMED in audit log", async () => { const poId = await createPaidPo(`${PREFIX}Audit`); await confirmReceipt({ poId }); const action = await db.pOAction.findFirst({ where: { poId, actionType: "RECEIPT_CONFIRMED" }, }); expect(action).not.toBeNull(); }); it("saves delivery notes on the Receipt record", async () => { const poId = await createPaidPo(`${PREFIX}Notes`); await confirmReceipt({ poId, notes: "All items received in good condition." }); const receipt = await db.receipt.findUnique({ where: { poId } }); expect(receipt?.notes).toBe("All items received in good condition."); }); }); describe("confirmReceipt — partial delivery", () => { it("transitions PAID_DELIVERED to PARTIALLY_CLOSED when some items remain", async () => { const poId = await createPaidPo(`${PREFIX}Partial`); const lineItems = await db.pOLineItem.findMany({ where: { poId } }); const deliveries: Record = {}; for (const li of lineItems) deliveries[li.id] = 0; // deliver nothing const result = await confirmReceipt({ poId, deliveries }); // delivering 0 of everything → nothingDelivered guard is in the UI, not the action // action still proceeds and computes PARTIALLY_CLOSED (paid but 0 delivered) expect(result).toEqual({ ok: true, partial: true }); const po = await db.purchaseOrder.findUnique({ where: { id: poId } }); // fully paid but nothing delivered → PARTIALLY_CLOSED expect(po?.status).toBe("PARTIALLY_CLOSED"); }); it("returns partial:true for a partial delivery", async () => { const poId = await createPaidPo(`${PREFIX}PartialQty`); const lineItems = await db.pOLineItem.findMany({ where: { poId } }); const half = Math.floor(Number(lineItems[0].quantity) / 2); const deliveries = { [lineItems[0].id]: half }; const result = await confirmReceipt({ poId, deliveries }); expect(result).toEqual({ ok: true, partial: true }); }); }); describe("confirmReceipt — repeated notes upsert (regression for partial → full flow)", () => { it("succeeds on second call with notes after first partial confirmation also had notes", async () => { const poId = await createPaidPo(`${PREFIX}Upsert`); const lineItems = await db.pOLineItem.findMany({ where: { poId } }); const half = Math.floor(Number(lineItems[0].quantity) / 2); const remaining = Number(lineItems[0].quantity) - half; // First confirmation: partial delivery with notes — creates Receipt row const first = await confirmReceipt({ poId, notes: "First batch received.", deliveries: { [lineItems[0].id]: half }, }); expect(first).toEqual({ ok: true, partial: true }); // Second confirmation: deliver the rest, also with notes — must not throw // (previously crashed due to unique constraint on Receipt.poId when using `create`) const second = await confirmReceipt({ poId, notes: "Remaining items received.", deliveries: { [lineItems[0].id]: remaining }, }); expect(second).toEqual({ ok: true, partial: false }); const po = await db.purchaseOrder.findUnique({ where: { id: poId } }); expect(po?.status).toBe("CLOSED"); // Notes should reflect the latest confirmation const receipt = await db.receipt.findUnique({ where: { poId } }); expect(receipt?.notes).toBe("Remaining items received."); }); }); describe("confirmReceipt — permission guards", () => { it("rejects non-submitter who is not SUPERUSER", async () => { const poId = await createPaidPo(`${PREFIX}PermFail`); const otherTech = await getSeedUser("tech@pelagia.local"); // Use a different user id to simulate a different submitter const fakeSession = makeSession(managerId, "TECHNICAL"); vi.mocked(auth as unknown as () => Promise).mockResolvedValue(fakeSession); const result = await confirmReceipt({ poId }); expect(result).toHaveProperty("error"); void otherTech; // suppress unused warning }); it("rejects confirmation on a PO in wrong status", async () => { // Create a PO that is still DRAFT (no payment yet) vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(techId, "TECHNICAL")); const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" }); const { id: poId } = (await createPo(form)) as { id: string }; const result = await confirmReceipt({ poId }); expect(result).toHaveProperty("error"); }); it("returns error when PO does not exist", async () => { const result = await confirmReceipt({ poId: "nonexistent-po-id" }); expect(result).toHaveProperty("error"); }); it("returns error when not authenticated", async () => { vi.mocked(auth as unknown as () => Promise).mockResolvedValue(null); const result = await confirmReceipt({ poId: "any-id" }); expect(result).toHaveProperty("error"); }); });