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
This commit is contained in:
parent
600f637de2
commit
9adc93e54a
2 changed files with 203 additions and 1 deletions
|
|
@ -109,7 +109,12 @@ export async function confirmReceipt({
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
|
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
|
||||||
receipt: notes
|
receipt: notes
|
||||||
? { create: { storageKey: "", fileName: "no-file", notes } }
|
? {
|
||||||
|
upsert: {
|
||||||
|
create: { storageKey: "", fileName: "no-file", notes },
|
||||||
|
update: { notes },
|
||||||
|
},
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
actions: {
|
actions: {
|
||||||
create: {
|
create: {
|
||||||
|
|
|
||||||
197
App/tests/integration/confirm-receipt.test.ts
Normal file
197
App/tests/integration/confirm-receipt.test.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<string, number> = {};
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue