Previously a PO's free-text line items only became reusable catalogue products (/inventory/items) on full payment (markPaid → syncProductCatalog). An approved- but-unpaid PO's items weren't selectable for further POs yet. - extract syncProductCatalog into lib/product-catalog.ts (shared). - call it from approvePo so approved items are immediately catalogued (create product by name if unknown, link the line item, upsert last/per-vendor price); payment still re-syncs to refresh prices. Idempotent. - test: approving a PO with a free-text line creates + links the product and records the per-vendor price. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
/**
|
|
* Integration tests for manager approval server actions.
|
|
* Covers: M-02 (approve / approve+note), M-03 (reject), M-04 (request edits, vendor ID), S-06 (provide vendor ID), S-07 (resubmit after edits).
|
|
*/
|
|
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } 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 { updatePo } from "@/app/(portal)/po/[id]/edit/actions";
|
|
import {
|
|
approvePo, rejectPo, requestEdits, requestVendorId,
|
|
} from "@/app/(portal)/approvals/[id]/actions";
|
|
import { provideVendorId } from "@/app/(portal)/po/[id]/actions";
|
|
import {
|
|
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
|
makePoForm, deletePosByTitle,
|
|
} from "./helpers";
|
|
|
|
const PREFIX = "INTTEST_APPROVAL_";
|
|
let techId: string;
|
|
let managerId: string;
|
|
let vesselId: string;
|
|
let accountId: string;
|
|
let vendorId: string;
|
|
|
|
beforeAll(async () => {
|
|
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
|
getSeedUser("tech@pelagia.local"),
|
|
getSeedUser("manager@pelagia.local"),
|
|
getSeedVessel("MV Poseidon"),
|
|
getSeedAccount("700201"),
|
|
getSeedVendor("Apar Industries Ltd"),
|
|
]);
|
|
techId = tech.id;
|
|
managerId = mgr.id;
|
|
vesselId = vessel.id;
|
|
accountId = account.id;
|
|
vendorId = vendor.id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await deletePosByTitle(PREFIX);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Products auto-created by the catalogue-on-approval test.
|
|
await db.productVendorPrice.deleteMany({ where: { product: { name: { startsWith: PREFIX } } } });
|
|
await db.product.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
|
});
|
|
|
|
// Helper: create a PO in MGR_REVIEW state
|
|
async function createSubmittedPo(title: string): Promise<string> {
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
|
const result = await createPo(form);
|
|
const id = (result as { id: string }).id;
|
|
// Vendor gating: a vendor must be assigned before a PO can be approved.
|
|
// Attach the seeded verified vendor directly (test setup) so approval-path tests run.
|
|
await db.purchaseOrder.update({ where: { id }, data: { vendorId } });
|
|
return id;
|
|
}
|
|
|
|
// ── M-02: Approve ─────────────────────────────────────────────────────────────
|
|
|
|
describe("M-02 — approve PO", () => {
|
|
it("transitions PO from MGR_REVIEW to MGR_APPROVED", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}Approve`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
const result = await approvePo({ poId });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("MGR_APPROVED");
|
|
expect(po?.approvedAt).not.toBeNull();
|
|
});
|
|
|
|
it("stores managerNote when approving with note", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}ApproveNote`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
await approvePo({ poId, note: "Approved — expedite delivery", withNote: true });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.managerNote).toBe("Approved — expedite delivery");
|
|
const action = await db.pOAction.findFirst({
|
|
where: { poId, actionType: "APPROVED_WITH_NOTE" },
|
|
});
|
|
expect(action).not.toBeNull();
|
|
});
|
|
|
|
it("notifies submitter and accounts on approval", async () => {
|
|
const { notify } = await import("@/lib/notifier");
|
|
vi.mocked(notify).mockClear();
|
|
const poId = await createSubmittedPo(`${PREFIX}ApproveNotify`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
await approvePo({ poId });
|
|
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "PO_APPROVED" })
|
|
);
|
|
});
|
|
|
|
it("returns error when TECHNICAL role tries to approve", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}ApproveForbidden`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const result = await approvePo({ poId });
|
|
expect(result).toHaveProperty("error");
|
|
});
|
|
|
|
it("returns error when PO is not in MGR_REVIEW state", async () => {
|
|
// Create a DRAFT PO, don't submit
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const form = makePoForm({ title: `${PREFIX}ApproveDraft`, vesselId, accountId, intent: "draft" });
|
|
const { id: poId } = (await createPo(form)) as { id: string };
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
const result = await approvePo({ poId });
|
|
expect(result).toHaveProperty("error");
|
|
});
|
|
});
|
|
|
|
// ── #92: Advance payment decided at approval ─────────────────────────────────
|
|
|
|
describe("issue #92 — advance payment on approval", () => {
|
|
it("persists the manager's advance amount and records it on the audit row", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}Advance`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
|
const half = Math.round(Number(before.totalAmount) / 2);
|
|
|
|
const result = await approvePo({ poId, suggestedAdvancePayment: half });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
|
expect(po.status).toBe("MGR_APPROVED");
|
|
expect(Number(po.suggestedAdvancePayment)).toBe(half);
|
|
|
|
const action = await db.pOAction.findFirst({ where: { poId, actionType: "APPROVED" } });
|
|
expect((action?.metadata as { suggestedAdvancePayment?: number } | null)?.suggestedAdvancePayment).toBe(half);
|
|
});
|
|
|
|
it("defaults to null (full payment) when no advance is provided", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}AdvanceNone`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
await approvePo({ poId });
|
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
|
expect(po.suggestedAdvancePayment).toBeNull();
|
|
});
|
|
|
|
it("clamps an advance above the PO total down to the total", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}AdvanceClamp`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
|
const total = Number(before.totalAmount);
|
|
|
|
await approvePo({ poId, suggestedAdvancePayment: total + 5000 });
|
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
|
expect(Number(po.suggestedAdvancePayment)).toBe(total);
|
|
});
|
|
});
|
|
|
|
// ── Product catalogue registered on approval (so items are reusable) ─────────
|
|
|
|
describe("product catalogue on approval", () => {
|
|
it("creates a catalogue product for a free-text line item and links it", async () => {
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const itemName = `${PREFIX}Starter VPS hosting`;
|
|
const form = makePoForm({
|
|
title: `${PREFIX}CatApprove`,
|
|
vesselId,
|
|
accountId,
|
|
intent: "submit",
|
|
lineItems: [{ description: itemName, quantity: 1, unit: "pc", unitPrice: 459.95 }],
|
|
});
|
|
const { id: poId } = (await createPo(form)) as { id: string };
|
|
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
|
|
|
|
// No catalogue product exists for this name before approval.
|
|
expect(await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } })).toBeNull();
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
expect(await approvePo({ poId })).toEqual({ ok: true });
|
|
|
|
const product = await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } });
|
|
expect(product).not.toBeNull();
|
|
expect(Number(product!.lastPrice)).toBe(459.95);
|
|
|
|
// The line item is linked back to the new product, and a per-vendor price is recorded.
|
|
const li = await db.pOLineItem.findFirstOrThrow({ where: { poId } });
|
|
expect(li.productId).toBe(product!.id);
|
|
const pvp = await db.productVendorPrice.findFirst({ where: { productId: product!.id, vendorId } });
|
|
expect(pvp).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── M-03: Reject ──────────────────────────────────────────────────────────────
|
|
|
|
describe("M-03 — reject PO", () => {
|
|
it("transitions PO from MGR_REVIEW to REJECTED with note", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}Reject`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("REJECTED");
|
|
expect(po?.managerNote).toBe("Budget exceeded for this quarter");
|
|
});
|
|
|
|
it("creates a REJECTED action entry in the audit trail", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}RejectAudit`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
await rejectPo({ poId, note: "Not needed" });
|
|
|
|
const action = await db.pOAction.findFirst({ where: { poId, actionType: "REJECTED" } });
|
|
expect(action?.note).toBe("Not needed");
|
|
});
|
|
|
|
it("notifies submitter on rejection", async () => {
|
|
const { notify } = await import("@/lib/notifier");
|
|
vi.mocked(notify).mockClear();
|
|
const poId = await createSubmittedPo(`${PREFIX}RejectNotify`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
await rejectPo({ poId, note: "See notes" });
|
|
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "PO_REJECTED" })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── M-04: Request edits ──────────────────────────────────────────────────────
|
|
|
|
describe("M-04 — request edits", () => {
|
|
it("transitions PO to EDITS_REQUESTED with manager note", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}Edits`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
const result = await requestEdits({ poId, note: "Please add vendor ID" });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("EDITS_REQUESTED");
|
|
expect(po?.managerNote).toBe("Please add vendor ID");
|
|
});
|
|
});
|
|
|
|
// ── M-04: Request vendor ID ──────────────────────────────────────────────────
|
|
|
|
describe("M-04 — request vendor ID", () => {
|
|
it("transitions PO to VENDOR_ID_PENDING", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}VendorIdReq`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
const result = await requestVendorId({ poId });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("VENDOR_ID_PENDING");
|
|
});
|
|
});
|
|
|
|
// ── S-06: Provide vendor ID ──────────────────────────────────────────────────
|
|
|
|
describe("S-06 — provide vendor ID", () => {
|
|
it("transitions VENDOR_ID_PENDING back to MGR_REVIEW", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}ProvideVendor`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
await requestVendorId({ poId });
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const result = await provideVendorId({ poId, vendorId });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("MGR_REVIEW");
|
|
expect(po?.vendorId).toBe(vendorId);
|
|
});
|
|
});
|
|
|
|
// ── Inventory update on approval ─────────────────────────────────────────────
|
|
|
|
describe("inventory — updated at MGR_APPROVED, not at closure", () => {
|
|
it("increments site inventory for line items with productId on approval", async () => {
|
|
const site = await db.site.findFirstOrThrow({ where: { code: "BOM" } });
|
|
const product = await db.product.findFirstOrThrow({ where: { code: "LUBE-EP80W90" } });
|
|
|
|
const before = await db.itemInventory.findUnique({
|
|
where: { productId_siteId: { productId: product.id, siteId: site.id } },
|
|
});
|
|
const qtyBefore = Number(before?.quantity ?? 0);
|
|
|
|
const po = await db.purchaseOrder.create({
|
|
data: {
|
|
poNumber: `INVTEST-${Date.now()}`,
|
|
title: `${PREFIX}InvApproval`,
|
|
status: "MGR_REVIEW",
|
|
totalAmount: 1000,
|
|
currency: "INR",
|
|
vesselId,
|
|
accountId,
|
|
vendorId,
|
|
siteId: site.id,
|
|
submitterId: techId,
|
|
submittedAt: new Date(),
|
|
lineItems: {
|
|
create: [{
|
|
name: "Gear Oil 80W90",
|
|
quantity: 5,
|
|
unit: "L",
|
|
unitPrice: 182,
|
|
totalPrice: 910,
|
|
gstRate: 0.18,
|
|
sortOrder: 0,
|
|
productId: product.id,
|
|
}],
|
|
},
|
|
actions: { create: { actionType: "SUBMITTED", actorId: techId } },
|
|
},
|
|
});
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
const result = await approvePo({ poId: po.id });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const after = await db.itemInventory.findUnique({
|
|
where: { productId_siteId: { productId: product.id, siteId: site.id } },
|
|
});
|
|
expect(Number(after?.quantity)).toBe(qtyBefore + 5);
|
|
});
|
|
|
|
it("skips inventory update for line items without a productId", async () => {
|
|
const site = await db.site.findFirstOrThrow({ where: { code: "BOM" } });
|
|
const countBefore = await db.itemInventory.count({ where: { siteId: site.id } });
|
|
|
|
const po = await db.purchaseOrder.create({
|
|
data: {
|
|
poNumber: `INVTEST-NOPROD-${Date.now()}`,
|
|
title: `${PREFIX}InvNoProduct`,
|
|
status: "MGR_REVIEW",
|
|
totalAmount: 500,
|
|
currency: "INR",
|
|
vesselId,
|
|
accountId,
|
|
vendorId,
|
|
siteId: site.id,
|
|
submitterId: techId,
|
|
submittedAt: new Date(),
|
|
lineItems: {
|
|
create: [{
|
|
name: "Ad-hoc supply",
|
|
quantity: 2,
|
|
unit: "pc",
|
|
unitPrice: 100,
|
|
totalPrice: 200,
|
|
gstRate: 0.18,
|
|
sortOrder: 0,
|
|
productId: null,
|
|
}],
|
|
},
|
|
actions: { create: { actionType: "SUBMITTED", actorId: techId } },
|
|
},
|
|
});
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
await approvePo({ poId: po.id });
|
|
|
|
const countAfter = await db.itemInventory.count({ where: { siteId: site.id } });
|
|
expect(countAfter).toBe(countBefore);
|
|
});
|
|
|
|
it("does not touch inventory for vessel-only POs (no siteId)", async () => {
|
|
const totalBefore = await db.itemInventory.count();
|
|
|
|
const po = await db.purchaseOrder.create({
|
|
data: {
|
|
poNumber: `INVTEST-VESSEL-${Date.now()}`,
|
|
title: `${PREFIX}InvVessel`,
|
|
status: "MGR_REVIEW",
|
|
totalAmount: 300,
|
|
currency: "INR",
|
|
vesselId,
|
|
accountId,
|
|
vendorId,
|
|
submitterId: techId,
|
|
submittedAt: new Date(),
|
|
lineItems: {
|
|
create: [{
|
|
name: "Vessel supply",
|
|
quantity: 3,
|
|
unit: "pc",
|
|
unitPrice: 50,
|
|
totalPrice: 150,
|
|
gstRate: 0.18,
|
|
sortOrder: 0,
|
|
}],
|
|
},
|
|
actions: { create: { actionType: "SUBMITTED", actorId: techId } },
|
|
},
|
|
});
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
await approvePo({ poId: po.id });
|
|
|
|
const totalAfter = await db.itemInventory.count();
|
|
expect(totalAfter).toBe(totalBefore);
|
|
});
|
|
});
|
|
|
|
// ── S-07: Edit and resubmit ──────────────────────────────────────────────────
|
|
|
|
describe("S-07 — edit and resubmit after edits requested", () => {
|
|
it("resubmitting from EDITS_REQUESTED transitions to MGR_REVIEW", async () => {
|
|
const poId = await createSubmittedPo(`${PREFIX}Resubmit`);
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
await requestEdits({ poId, note: "Update line items" });
|
|
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "resubmit" });
|
|
const result = await updatePo(poId, form);
|
|
expect(result).toEqual({ id: poId });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("MGR_REVIEW");
|
|
});
|
|
|
|
it("saving edits without resubmitting stays as DRAFT (save intent)", async () => {
|
|
// Create a DRAFT PO
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const form = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
|
|
const { id: poId } = (await createPo(form)) as { id: string };
|
|
|
|
const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
|
|
const result = await updatePo(poId, editForm);
|
|
expect(result).toEqual({ id: poId });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("DRAFT");
|
|
});
|
|
});
|