diff --git a/App/app/(portal)/po/[id]/receipt/actions.ts b/App/app/(portal)/po/[id]/receipt/actions.ts index aa227f9..1d045a0 100644 --- a/App/app/(portal)/po/[id]/receipt/actions.ts +++ b/App/app/(portal)/po/[id]/receipt/actions.ts @@ -109,7 +109,12 @@ export async function confirmReceipt({ status: newStatus, closedAt: newStatus === "CLOSED" ? new Date() : undefined, receipt: notes - ? { create: { storageKey: "", fileName: "no-file", notes } } + ? { + upsert: { + create: { storageKey: "", fileName: "no-file", notes }, + update: { notes }, + }, + } : undefined, actions: { create: { diff --git a/App/tests/integration/confirm-receipt.test.ts b/App/tests/integration/confirm-receipt.test.ts new file mode 100644 index 0000000..895a40a --- /dev/null +++ b/App/tests/integration/confirm-receipt.test.ts @@ -0,0 +1,197 @@ +/** + * 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).mockResolvedValue(makeSession(techId, "TECHNICAL")); + const form = makePoForm({ title, vesselId, accountId, intent: "submit" }); + const { id: poId } = (await createPo(form)) as { id: string }; + + vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); + await approvePo({ poId }); + + vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); + await processPayment({ poId }); + await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY }); + + vi.mocked(auth).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).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).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).mockResolvedValue(null); + const result = await confirmReceipt({ poId: "any-id" }); + expect(result).toHaveProperty("error"); + }); +});