From 9adc93e54aaf49fe61fe625102fa89fe6165c930 Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Fri, 19 Jun 2026 04:01:26 +0530 Subject: [PATCH] fix(receipt): upsert Receipt record on repeat confirmations with notes Partial-receipt flows call confirmReceipt multiple times. The nested `create` on the Receipt relation threw a unique-constraint error on the second call when both confirmations supplied notes, preventing any delivery from completing and blocking attachment uploads. Changed to `upsert` so subsequent confirmations update the existing Receipt row's notes instead of failing. Adds integration tests covering full receipt, partial receipt, the upsert scenario (two confirmations each with notes), and permission guards. Fixes #9 --- App/app/(portal)/po/[id]/receipt/actions.ts | 7 +- App/tests/integration/confirm-receipt.test.ts | 197 ++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 App/tests/integration/confirm-receipt.test.ts 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"); + }); +});