pelagia-portal/App/tests/integration/cancel-supersede.test.ts
Hardik 0b10ba5e54
All checks were successful
PR checks / checks (pull_request) Successful in 32s
feat(po): cancel POs (manager/superuser) + optional supersede link (#53)
Managers and superusers can cancel a PO from any state via a confirmation modal
that requires typing "cancel" and a mandatory reason. A cancelled PO becomes a
terminal CANCELLED state and drops out of every spend tracker/graph (those filter
on POST_APPROVAL_STATUSES / explicit whitelists, none of which include CANCELLED).

A cancelled PO may optionally be linked to the existing PO that supersedes it
(by PO number); the replacement shows the reciprocal "supersedes" link. No
vessel/account/vendor match is enforced and the link can be added any time.

Cancelled POs remain visible (greyed in history) and exportable, with a diagonal
"CANCELLED" watermark on both the PDF and XLSX exports.

- schema: POStatus CANCELLED; cancelledAt/cancellationReason; self-referential
  supersededById relation; ActionType CANCELLED/SUPERSEDED (+ migration)
- state machine canCancel(); cancel_po permission (MANAGER + SUPERUSER)
- cancelPo / supersedePo server actions + PO_CANCELLED notification
- cancel modal + supersede form; cancelled banner with reciprocal links
- exhaustive CANCELLED entries in all status label/variant maps
- diagonal CANCELLED watermark embedded for PDF (CSS) and XLSX (image)
- integration tests (cancel from any state, reason/role guards, supersede)

Inventory reversal on cancel is deferred to #55 (inventory is feature-flagged off).

Closes #53

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:20:54 +05:30

181 lines
7.8 KiB
TypeScript

/**
* 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<string> {
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." });
});
});