192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
/**
|
||
* Integration tests for PO creation server action.
|
||
* Covers: S-01 (create with line items), S-02 (save as draft), S-03 (submit for approval).
|
||
*/
|
||
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 {
|
||
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||
makePoForm, deletePosByTitle,
|
||
} from "./helpers";
|
||
|
||
const PREFIX = "INTTEST_CREATE_";
|
||
let techId: string;
|
||
let vesselId: string;
|
||
let accountId: string;
|
||
let vendorId: string;
|
||
|
||
beforeAll(async () => {
|
||
const [tech, vessel, account, vendor] = await Promise.all([
|
||
getSeedUser("tech@pelagia.local"),
|
||
getSeedVessel("MV Ocean Pride"),
|
||
getSeedAccount("700201"),
|
||
getSeedVendor("Apar Industries Ltd"),
|
||
]);
|
||
techId = tech.id;
|
||
vesselId = vessel.id;
|
||
accountId = account.id;
|
||
vendorId = vendor.id;
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await deletePosByTitle(PREFIX);
|
||
});
|
||
|
||
// ── S-02: Save as draft ──────────────────────────────────────────────────────
|
||
|
||
describe("S-02 — save as draft", () => {
|
||
it("creates a PO in DRAFT status", async () => {
|
||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||
|
||
const form = makePoForm({
|
||
title: `${PREFIX}Draft`,
|
||
vesselId, accountId, intent: "draft",
|
||
});
|
||
const result = await createPo(form);
|
||
|
||
expect(result).not.toHaveProperty("error");
|
||
const id = (result as { id: string }).id;
|
||
const po = await db.purchaseOrder.findUnique({ where: { id } });
|
||
expect(po?.status).toBe("DRAFT");
|
||
expect(po?.submittedAt).toBeNull();
|
||
});
|
||
|
||
it("returns error for unauthenticated request", async () => {
|
||
vi.mocked(auth).mockResolvedValue(null);
|
||
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
|
||
const result = await createPo(form);
|
||
expect(result).toEqual({ error: "Unauthorized" });
|
||
});
|
||
|
||
it("returns error when ACCOUNTS role tries to create a PO", async () => {
|
||
const acct = await getSeedUser("accounts@pelagia.local");
|
||
vi.mocked(auth).mockResolvedValue(makeSession(acct.id, "ACCOUNTS"));
|
||
const form = makePoForm({ title: `${PREFIX}ForbiddenAccts`, vesselId, accountId });
|
||
const result = await createPo(form);
|
||
expect(result).toHaveProperty("error");
|
||
});
|
||
|
||
it("returns error when a required field (vesselId) is missing", async () => {
|
||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||
const form = new FormData();
|
||
form.set("title", `${PREFIX}NoVessel`);
|
||
form.set("accountId", accountId);
|
||
form.set("intent", "draft");
|
||
form.set("lineItems[0].description", "Item");
|
||
form.set("lineItems[0].quantity", "1");
|
||
form.set("lineItems[0].unit", "pc");
|
||
form.set("lineItems[0].unitPrice", "50");
|
||
form.set("lineItems[0].gstRate", "0.18");
|
||
const result = await createPo(form);
|
||
expect(result).toHaveProperty("error");
|
||
});
|
||
});
|
||
|
||
// ── S-01: Create with line items ─────────────────────────────────────────────
|
||
|
||
describe("S-01 — create PO with line items", () => {
|
||
it("stores line items with correct quantity, unit price, and GST rate", async () => {
|
||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||
|
||
const form = makePoForm({
|
||
title: `${PREFIX}LineItems`,
|
||
vesselId, accountId, vendorId, intent: "draft",
|
||
lineItems: [
|
||
{ description: "Gear Oil 80W90", quantity: 50, unit: "L", unitPrice: 182, gstRate: 0.18 },
|
||
{ description: "Engine Filter", quantity: 4, unit: "pc", unitPrice: 250, gstRate: 0.12 },
|
||
],
|
||
});
|
||
const result = await createPo(form);
|
||
const id = (result as { id: string }).id;
|
||
|
||
const po = await db.purchaseOrder.findUnique({
|
||
where: { id },
|
||
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
|
||
});
|
||
|
||
expect(po?.lineItems).toHaveLength(2);
|
||
expect(Number(po!.lineItems[0].quantity)).toBe(50);
|
||
expect(Number(po!.lineItems[0].unitPrice)).toBe(182);
|
||
expect(Number(po!.lineItems[0].gstRate)).toBeCloseTo(0.18);
|
||
expect(Number(po!.lineItems[1].unitPrice)).toBe(250);
|
||
expect(Number(po!.lineItems[1].gstRate)).toBeCloseTo(0.12);
|
||
});
|
||
|
||
it("sets totalAmount to grand total including GST", async () => {
|
||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||
|
||
// 10 × 100 × 1.18 = 1180
|
||
const form = makePoForm({
|
||
title: `${PREFIX}GrandTotal`,
|
||
vesselId, accountId, intent: "draft",
|
||
lineItems: [{ description: "Item", quantity: 10, unit: "pc", unitPrice: 100, gstRate: 0.18 }],
|
||
});
|
||
const result = await createPo(form);
|
||
const id = (result as { id: string }).id;
|
||
const po = await db.purchaseOrder.findUnique({ where: { id } });
|
||
expect(Number(po!.totalAmount)).toBeCloseTo(1180, 1);
|
||
});
|
||
|
||
it("stores optional fields (PI quotation no, place of delivery, TC fields)", async () => {
|
||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||
|
||
const form = makePoForm({ title: `${PREFIX}Optional`, vesselId, accountId, intent: "draft" });
|
||
form.set("piQuotationNo", "Verbal");
|
||
form.set("placeOfDelivery", "CBD Belapur, Navi Mumbai");
|
||
form.set("tcDelivery", "Within 7 days");
|
||
form.set("tcPaymentTerms", "Net 45");
|
||
const result = await createPo(form);
|
||
const id = (result as { id: string }).id;
|
||
const po = await db.purchaseOrder.findUnique({ where: { id } });
|
||
|
||
expect((po as any).piQuotationNo).toBe("Verbal");
|
||
expect((po as any).placeOfDelivery).toBe("CBD Belapur, Navi Mumbai");
|
||
expect((po as any).tcDelivery).toBe("Within 7 days");
|
||
expect((po as any).tcPaymentTerms).toBe("Net 45");
|
||
});
|
||
|
||
it("allows MANNING role to create a PO", async () => {
|
||
const manning = await getSeedUser("manning@pelagia.local");
|
||
vi.mocked(auth).mockResolvedValue(makeSession(manning.id, "MANNING"));
|
||
const form = makePoForm({ title: `${PREFIX}Manning`, vesselId, accountId });
|
||
const result = await createPo(form);
|
||
expect(result).not.toHaveProperty("error");
|
||
});
|
||
});
|
||
|
||
// ── S-03: Submit for approval ─────────────────────────────────────────────────
|
||
|
||
describe("S-03 — submit for approval", () => {
|
||
it("creates PO with status MGR_REVIEW and sets submittedAt", async () => {
|
||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||
|
||
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
|
||
const result = await createPo(form);
|
||
|
||
expect(result).not.toHaveProperty("error");
|
||
const id = (result as { id: string }).id;
|
||
const po = await db.purchaseOrder.findUnique({ where: { id } });
|
||
expect(po?.status).toBe("MGR_REVIEW");
|
||
expect(po?.submittedAt).not.toBeNull();
|
||
});
|
||
|
||
it("sends notification to managers on submit", async () => {
|
||
const { notify } = await import("@/lib/notifier");
|
||
vi.mocked(notify).mockClear();
|
||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||
|
||
const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" });
|
||
await createPo(form);
|
||
|
||
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
||
expect.objectContaining({ event: "PO_SUBMITTED" })
|
||
);
|
||
});
|
||
});
|