All checks were successful
PR checks / checks (pull_request) Successful in 32s
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>
181 lines
7.8 KiB
TypeScript
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." });
|
|
});
|
|
});
|