- resubmit: updatePo distinguishes intent "resubmit" (from EDITS_REQUESTED) from "submit" (from DRAFT); test now sends "resubmit" (makePoForm widened). - payment: MANAGER now holds process_payment, so the "wrong permission" negative test uses TECHNICAL (which lacks it). - vendor: provideVendorId rejects on a missing vendorId *code*; seeded unverified vendors carry codes, so create a genuinely code-less vendor. Full integration suite: 108/108 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
/**
|
|
* Integration tests for vendor-gated approval and provide-vendor-id.
|
|
* Covers:
|
|
* - Approval blocked when no vendor assigned
|
|
* - Approval succeeds once vendor is set
|
|
* - ACCOUNTS role can now call provideVendorId
|
|
* - Unverified vendor rejected by provideVendorId
|
|
* - AUDITOR cannot provide vendor ID
|
|
*/
|
|
import { vi, describe, it, expect, beforeAll, afterAll, 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 { approvePo, 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_VENDOR_APPROVAL_";
|
|
let techId: string;
|
|
let managerId: string;
|
|
let accountsId: string;
|
|
let auditorId: string;
|
|
let vesselId: string;
|
|
let accountId: string;
|
|
let verifiedVendorId: string;
|
|
let unverifiedVendorDbId: string;
|
|
|
|
beforeAll(async () => {
|
|
const [tech, mgr, acct, vessel, account, vendor] = await Promise.all([
|
|
getSeedUser("tech@pelagia.local"),
|
|
getSeedUser("manager@pelagia.local"),
|
|
getSeedUser("accounts@pelagia.local"),
|
|
getSeedVessel("MV Pelagia Star"),
|
|
getSeedAccount("700201"),
|
|
getSeedVendor("Apar Industries Ltd"),
|
|
]);
|
|
techId = tech.id;
|
|
managerId = mgr.id;
|
|
accountsId = acct.id;
|
|
vesselId = vessel.id;
|
|
accountId = account.id;
|
|
verifiedVendorId = vendor.id;
|
|
|
|
// Auditor — create on-the-fly if not seeded
|
|
const maybeAuditor = await db.user.findFirst({ where: { role: "AUDITOR" } });
|
|
if (maybeAuditor) {
|
|
auditorId = maybeAuditor.id;
|
|
} else {
|
|
const created = await db.user.create({
|
|
data: {
|
|
employeeId: "EMP-TEST-AUD",
|
|
email: "auditor@test.local",
|
|
name: "Test Auditor",
|
|
passwordHash: "irrelevant",
|
|
role: "AUDITOR",
|
|
},
|
|
});
|
|
auditorId = created.id;
|
|
}
|
|
|
|
// A vendor with no formal vendorId code — provideVendorId must reject it.
|
|
// (Seeded "unverified" vendors can still carry a code, so create a code-less one.)
|
|
const noCode = await db.vendor.create({
|
|
data: { name: `${PREFIX}NoCodeVendor`, isVerified: false, vendorId: null },
|
|
});
|
|
unverifiedVendorDbId = noCode.id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await deletePosByTitle(PREFIX);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.vendor.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
|
});
|
|
|
|
async function makeReviewPo(title: string, withVendor = false) {
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
|
const form = makePoForm({
|
|
title,
|
|
vesselId,
|
|
accountId,
|
|
intent: "submit",
|
|
vendorId: withVendor ? verifiedVendorId : undefined,
|
|
});
|
|
const result = await createPo(form);
|
|
return (result as { id: string }).id;
|
|
}
|
|
|
|
// ── Vendor required for approval ──────────────────────────────────────────────
|
|
|
|
describe("approval — vendor required", () => {
|
|
it("blocks approval when PO has no vendor assigned", async () => {
|
|
const poId = await makeReviewPo(`${PREFIX}NoVendorBlock`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
|
|
const result = await approvePo({ poId });
|
|
expect(result).toHaveProperty("error");
|
|
expect((result as { error: string }).error).toMatch(/vendor/i);
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("MGR_REVIEW");
|
|
});
|
|
|
|
it("allows approval when PO has a vendor assigned", async () => {
|
|
const poId = await makeReviewPo(`${PREFIX}VendorPresent`, true);
|
|
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");
|
|
});
|
|
});
|
|
|
|
// ── provideVendorId — role expansion ─────────────────────────────────────────
|
|
|
|
describe("provideVendorId — role expansion", () => {
|
|
async function makePendingPo(title: string) {
|
|
const poId = await makeReviewPo(title);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
|
await requestVendorId({ poId });
|
|
return poId;
|
|
}
|
|
|
|
it("ACCOUNTS can provide a verified vendor ID", async () => {
|
|
const poId = await makePendingPo(`${PREFIX}AccountsProvide`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
|
|
|
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("MGR_REVIEW");
|
|
expect(po?.vendorId).toBe(verifiedVendorId);
|
|
});
|
|
|
|
it("rejects an unverified vendor (no vendorId field on Vendor record)", async () => {
|
|
const poId = await makePendingPo(`${PREFIX}UnverifiedVendor`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
|
|
|
const result = await provideVendorId({ poId, vendorId: unverifiedVendorDbId });
|
|
expect(result).toHaveProperty("error");
|
|
|
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
|
expect(po?.status).toBe("VENDOR_ID_PENDING");
|
|
});
|
|
|
|
it("AUDITOR cannot provide vendor ID", async () => {
|
|
const poId = await makePendingPo(`${PREFIX}AuditorDenied`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(auditorId, "AUDITOR"));
|
|
|
|
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
|
expect(result).toHaveProperty("error");
|
|
});
|
|
|
|
it("returns error when called on a PO not in VENDOR_ID_PENDING state", async () => {
|
|
// PO still in MGR_REVIEW — no requestVendorId called
|
|
const poId = await makeReviewPo(`${PREFIX}WrongState`);
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
|
|
|
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
|
expect(result).toHaveProperty("error");
|
|
});
|
|
});
|