/** * Integration tests for PO cancellation and supersede linkage. * Covers: cancel from any state (MANAGER/SUPERUSER, reason required), exclusion * from spend aggregation, and linking a cancelled PO to an existing replacement. * * POs are built directly via db.create (not the makePoForm helper) so the test is * self-contained and cleans up cascade-safely (POAction has no onDelete: Cascade). */ 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 { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions"; import { POST_APPROVAL_STATUSES } from "@/lib/utils"; import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor } from "./helpers"; import type { POStatus } from "@prisma/client"; const mockedAuth = vi.mocked(auth); const PREFIX = "INTTEST_CANCEL_"; let techId: string; let managerId: string; let vesselId: string; let accountId: string; let vendorId: string; let seq = 0; beforeAll(async () => { const [tech, mgr, vessel, account, vendor] = await Promise.all([ getSeedUser("tech@pelagia.local"), getSeedUser("manager@pelagia.local"), getSeedVessel("MV Galatea"), getSeedAccount("700201"), getSeedVendor("Apar Industries Ltd"), ]); techId = tech.id; managerId = mgr.id; vesselId = vessel.id; accountId = account.id; vendorId = vendor.id; }); afterEach(async () => { const pos = await db.purchaseOrder.findMany({ where: { title: { startsWith: PREFIX } }, select: { id: true } }); const ids = pos.map((p) => p.id); if (ids.length === 0) return; await db.purchaseOrder.updateMany({ where: { id: { in: ids } }, data: { supersededById: null } }); await db.pOAction.deleteMany({ where: { poId: { in: ids } } }); await db.purchaseOrder.deleteMany({ where: { id: { in: ids } } }); }); async function makePo(label: string, status: POStatus): Promise { seq += 1; const po = await db.purchaseOrder.create({ data: { poNumber: `CANCELTEST-${seq}-${label}`, title: `${PREFIX}${label}`, status, totalAmount: 1180, currency: "INR", vesselId, accountId, submitterId: techId, ...(status === "MGR_APPROVED" ? { vendorId, approvedAt: new Date() } : {}), lineItems: { create: [{ name: "Test Item", quantity: 10, unit: "pc", unitPrice: 100, totalPrice: 1180, gstRate: 0.18, sortOrder: 0 }] }, actions: { create: { actionType: "CREATED", actorId: techId } }, }, }); return po.id; } describe("cancelPo", () => { it("cancels a DRAFT PO with a reason and writes an audit row", async () => { const poId = await makePo("Draft", "DRAFT"); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); const result = await cancelPo({ poId, reason: "Duplicate order" }); expect(result).toEqual({ ok: true }); const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); expect(po.status).toBe("CANCELLED"); expect(po.cancelledAt).not.toBeNull(); expect(po.cancellationReason).toBe("Duplicate order"); const action = await db.pOAction.findFirst({ where: { poId, actionType: "CANCELLED" } }); expect(action?.note).toBe("Duplicate order"); }); it("cancels an already-APPROVED PO (cancellable from any state)", async () => { const poId = await makePo("Approved", "MGR_APPROVED"); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); const result = await cancelPo({ poId, reason: "Vendor backed out" }); expect(result).toEqual({ ok: true }); const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); expect(po.status).toBe("CANCELLED"); }); it("a cancelled PO drops out of the spend aggregation filter", async () => { const poId = await makePo("Spend", "MGR_APPROVED"); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); await cancelPo({ poId, reason: "Excluded from spend" }); expect(POST_APPROVAL_STATUSES as readonly string[]).not.toContain("CANCELLED"); const stillCounted = await db.purchaseOrder.findFirst({ where: { id: poId, status: { in: [...POST_APPROVAL_STATUSES] } }, }); expect(stillCounted).toBeNull(); }); it("requires a reason", async () => { const poId = await makePo("NoReason", "DRAFT"); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); const result = await cancelPo({ poId, reason: " " }); expect(result).toEqual({ error: "A cancellation reason is required." }); }); it("refuses a role without cancel_po (TECHNICAL)", async () => { const poId = await makePo("Forbidden", "DRAFT"); mockedAuth.mockResolvedValue(makeSession(techId, "TECHNICAL") as never); const result = await cancelPo({ poId, reason: "nope" }); expect(result).toHaveProperty("error"); const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); expect(po.status).toBe("DRAFT"); }); it("refuses to cancel an already-cancelled PO", async () => { const poId = await makePo("Twice", "DRAFT"); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); await cancelPo({ poId, reason: "first" }); const result = await cancelPo({ poId, reason: "second" }); expect(result).toEqual({ error: "This purchase order is already cancelled." }); }); }); describe("supersedePo", () => { it("links a cancelled PO to an existing replacement (reciprocal)", async () => { const cancelledId = await makePo("Old", "DRAFT"); const replacementId = await makePo("New", "DRAFT"); const replacement = await db.purchaseOrder.findUniqueOrThrow({ where: { id: replacementId } }); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); await cancelPo({ poId: cancelledId, reason: "Replaced" }); const result = await supersedePo({ poId: cancelledId, replacementPoNumber: replacement.poNumber }); expect(result).toEqual({ ok: true }); const old = await db.purchaseOrder.findUniqueOrThrow({ where: { id: cancelledId } }); expect(old.supersededById).toBe(replacementId); const repl = await db.purchaseOrder.findUniqueOrThrow({ where: { id: replacementId }, include: { supersedes: { select: { id: true } } }, }); expect(repl.supersedes.map((s) => s.id)).toContain(cancelledId); }); it("refuses to supersede a PO that is not cancelled", async () => { const poId = await makePo("NotCancelled", "DRAFT"); const otherId = await makePo("Other", "DRAFT"); const other = await db.purchaseOrder.findUniqueOrThrow({ where: { id: otherId } }); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); const result = await supersedePo({ poId, replacementPoNumber: other.poNumber }); expect(result).toEqual({ error: "Only a cancelled purchase order can be superseded." }); }); it("rejects an unknown replacement PO number", async () => { const poId = await makePo("Unknown", "DRAFT"); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); await cancelPo({ poId, reason: "x" }); const result = await supersedePo({ poId, replacementPoNumber: "PMS/ZZZ/0000/2000-01" }); expect(result).toHaveProperty("error"); }); it("rejects self-supersede", async () => { const poId = await makePo("Self", "DRAFT"); const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never); await cancelPo({ poId, reason: "x" }); const result = await supersedePo({ poId, replacementPoNumber: po.poNumber }); expect(result).toEqual({ error: "A purchase order cannot supersede itself." }); }); });