test: add comprehensive tests for all new features + test plan

Parser extraction:
- Move parseSheet/parseWorkbook/cellStr/cellNum to lib/po-import-parser.ts
  so they can be unit-tested without HTTP overhead
- Route now re-exports types and delegates to the lib

Unit tests (165 total, all passing):
- permissions.test.ts: +15 cases covering MANAGER create_po/submit_po/
  manage_vendors, ACCOUNTS manage_vendors, AUDITOR all-denied, ADMIN
  operational denial, SUPERUSER no manage_vendors
- po-state-machine.test.ts: +12 cases covering MANAGER submit from DRAFT
  and EDITS_REQUESTED, ACCOUNTS provide_vendor_id, AUDITOR/ADMIN denied
  on all transitions
- po-import-parser.test.ts (new, 32 cases): cellStr/cellNum edge cases;
  parseSheet against real Sample_PO.xlsx (1 line item, correct values,
  T&C not included, vendor/quotation/T&C extraction); synthetic sheet
  edge cases (GST normalisation, INSTRUCTIONS stop, zero-price skip,
  empty rows); parseWorkbook happy path and empty-workbook

Integration tests (new files):
- discard-po.test.ts: owner/MANAGER/SUPERUSER can discard; ACCOUNTS and
  non-owners denied; status guard blocks non-DRAFT; cascade cleanup of
  POActions and POLineItems verified in DB
- vendor-approval.test.ts: approval blocked without vendor; approval
  succeeds with vendor; ACCOUNTS can provideVendorId; unverified vendor
  rejected; AUDITOR denied; wrong-status denied
- manager-po-creation.test.ts: MANAGER creates DRAFT and submits; stores
  correct submitterId; can discard own draft; ACCOUNTS denied; unauth
  returns Unauthorized
- products-search.test.ts: 401 unauth; min-length validation; search by
  name/code/description; case-insensitive; max 10 results; lastPrice as
  number; inactive products excluded
- import-api.test.ts: 401 unauth; 403 for TECHNICAL and ACCOUNTS; 400
  no file; 400 invalid binary; 200 for MANAGER with Sample_PO.xlsx;
  correct line item values; T&C absent from results; vendor/PI extracted

Spec/TEST_PLAN.md (new):
- Testing strategy, stack, and environment setup
- Coverage matrix across unit/integration/E2E layers
- Permission test matrix for all 7 roles × 15 operations
- Feature-level scenario index (F-01 through F-06) with IDs mapping to test files
- Known gaps and out-of-scope items
- Authoring conventions (PREFIX isolation, negative-first, no any)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-09 19:15:58 +05:30
parent 4a848f50cf
commit 48e1f19e58
10 changed files with 1280 additions and 124 deletions

View file

@ -1,121 +1,9 @@
import { auth } from "@/auth";
import { NextRequest, NextResponse } from "next/server";
import * as XLSX from "xlsx";
import { parseSheet } from "@/lib/po-import-parser";
export type ParsedImportLine = {
description: string;
unit: string;
quantity: number;
unitPrice: number;
gstRate: number;
};
export type ParsedImport = {
poNumber: string;
piQuotationNo: string;
placeOfDelivery: string;
tcDelivery: string;
tcDispatch: string;
tcInspection: string;
tcTransitInsurance: string;
tcPaymentTerms: string;
tcOthers: string;
vendorName: string;
vendorAddress: string;
vendorContact: string;
lineItems: ParsedImportLine[];
};
function cellStr(sheet: XLSX.WorkSheet, row: number, col: number): string {
const addr = XLSX.utils.encode_cell({ r: row, c: col });
const cell = sheet[addr];
if (!cell) return "";
return String(cell.v ?? "").trim();
}
function cellNum(sheet: XLSX.WorkSheet, row: number, col: number): number {
const addr = XLSX.utils.encode_cell({ r: row, c: col });
const cell = sheet[addr];
if (!cell) return 0;
const v = parseFloat(String(cell.v));
return isNaN(v) ? 0 : v;
}
function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
// Row 4: PO Number at col 2
const poNumber = cellStr(sheet, 4, 2);
// Row 5: PI/Quotation No at col 2
const piQuotationNo = cellStr(sheet, 5, 2);
// Row 8: Place of Delivery at col 2
const placeOfDelivery = cellStr(sheet, 8, 2);
// Row 12: Vendor Name at col 2, Vendor Address at col 3
const vendorName = cellStr(sheet, 12, 2);
const vendorAddress = cellStr(sheet, 12, 3);
// Row 13: Contact at col 2
const vendorContact = cellStr(sheet, 13, 2);
// T&C rows 28-33 (col 1)
const tcDelivery = cellStr(sheet, 28, 1).replace(/^DELIVERY\s*:\s*/i, "").trim();
const tcDispatch = cellStr(sheet, 29, 1).replace(/^DISPATCH INSTRUCTIONS:\s*/i, "").trim();
const tcInspection = cellStr(sheet, 30, 1).replace(/^INSPECTION\s*:\s*/i, "").trim();
const tcTransitInsurance = cellStr(sheet, 31, 1).replace(/^TRANSIT INSURANCE:\s*/i, "").trim();
const tcPaymentTerms = cellStr(sheet, 32, 1).replace(/^PAYMENT TERMS:\s*/i, "").trim();
const tcOthers = cellStr(sheet, 33, 1).trim();
// Line items start at row 15 (index 15)
const lineItems: ParsedImportLine[] = [];
for (let r = 15; r <= 100; r++) {
const sn = cellStr(sheet, r, 0);
const desc = cellStr(sheet, r, 1);
// "INSTRUCTIONS TO VENDORS" in col 0 signals the T&C section — stop here
if (sn.toUpperCase().includes("INSTRUCTION")) break;
if (!desc && !sn) continue;
if (!desc) continue;
// Stop at summary/total rows in description column
if (desc.toLowerCase().includes("total") || desc.toLowerCase().includes("grand")) break;
const unitRaw = cellStr(sheet, r, 3);
const qty = cellNum(sheet, r, 4);
const unitPrice = cellNum(sheet, r, 5);
// Skip rows with no quantity and no unit price — these are T&C text rows
if (qty === 0 && unitPrice === 0) continue;
const gstRaw = cellNum(sheet, r, 7);
const gstRate = gstRaw > 1 ? gstRaw / 100 : gstRaw;
lineItems.push({
description: desc,
unit: unitRaw || "pc",
quantity: qty || 1,
unitPrice,
gstRate: gstRate || 0.18,
});
}
return {
poNumber,
piQuotationNo,
placeOfDelivery,
tcDelivery,
tcDispatch,
tcInspection,
tcTransitInsurance,
tcPaymentTerms,
tcOthers,
vendorName,
vendorAddress,
vendorContact,
lineItems,
};
}
export type { ParsedImportLine, ParsedImport } from "@/lib/po-import-parser";
export async function POST(req: NextRequest) {
const session = await auth();
@ -141,24 +29,28 @@ export async function POST(req: NextRequest) {
try {
workbook = XLSX.read(buffer, { type: "buffer" });
} catch {
return NextResponse.json({ error: "Could not parse Excel file. Ensure it is a valid .xlsx file." }, { status: 400 });
return NextResponse.json(
{ error: "Could not parse Excel file. Ensure it is a valid .xlsx file." },
{ status: 400 }
);
}
const results: ParsedImport[] = [];
const results = [];
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
try {
const parsed = parseSheet(sheet);
if (parsed.lineItems.length > 0) {
results.push(parsed);
}
if (parsed.lineItems.length > 0) results.push(parsed);
} catch {
// skip unparseable sheets
}
}
if (results.length === 0) {
return NextResponse.json({ error: "No valid purchase order data found in the file." }, { status: 400 });
return NextResponse.json(
{ error: "No valid purchase order data found in the file." },
{ status: 400 }
);
}
return NextResponse.json({ results });

View file

@ -0,0 +1,120 @@
import * as XLSX from "xlsx";
export type ParsedImportLine = {
description: string;
unit: string;
quantity: number;
unitPrice: number;
gstRate: number;
};
export type ParsedImport = {
poNumber: string;
piQuotationNo: string;
placeOfDelivery: string;
tcDelivery: string;
tcDispatch: string;
tcInspection: string;
tcTransitInsurance: string;
tcPaymentTerms: string;
tcOthers: string;
vendorName: string;
vendorAddress: string;
vendorContact: string;
lineItems: ParsedImportLine[];
};
export function cellStr(sheet: XLSX.WorkSheet, row: number, col: number): string {
const addr = XLSX.utils.encode_cell({ r: row, c: col });
const cell = sheet[addr];
if (!cell) return "";
return String(cell.v ?? "").trim();
}
export function cellNum(sheet: XLSX.WorkSheet, row: number, col: number): number {
const addr = XLSX.utils.encode_cell({ r: row, c: col });
const cell = sheet[addr];
if (!cell) return 0;
const v = parseFloat(String(cell.v));
return isNaN(v) ? 0 : v;
}
export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
const poNumber = cellStr(sheet, 4, 2);
const piQuotationNo = cellStr(sheet, 5, 2);
const placeOfDelivery = cellStr(sheet, 8, 2);
const vendorName = cellStr(sheet, 12, 2);
const vendorAddress = cellStr(sheet, 12, 3);
const vendorContact = cellStr(sheet, 13, 2);
// T&C from instruction rows 2833 (col 1)
const tcDelivery = cellStr(sheet, 28, 1).replace(/^DELIVERY\s*:\s*/i, "").trim();
const tcDispatch = cellStr(sheet, 29, 1).replace(/^DISPATCH INSTRUCTIONS:\s*/i, "").trim();
const tcInspection = cellStr(sheet, 30, 1).replace(/^INSPECTION\s*:\s*/i, "").trim();
const tcTransitInsurance = cellStr(sheet, 31, 1).replace(/^TRANSIT INSURANCE:\s*/i, "").trim();
const tcPaymentTerms = cellStr(sheet, 32, 1).replace(/^PAYMENT TERMS:\s*/i, "").trim();
const tcOthers = cellStr(sheet, 33, 1).trim();
const lineItems: ParsedImportLine[] = [];
for (let r = 15; r <= 100; r++) {
const sn = cellStr(sheet, r, 0);
const desc = cellStr(sheet, r, 1);
// "INSTRUCTIONS TO VENDORS" in col 0 signals the T&C section — stop here
if (sn.toUpperCase().includes("INSTRUCTION")) break;
if (!desc && !sn) continue;
if (!desc) continue;
if (desc.toLowerCase().includes("total") || desc.toLowerCase().includes("grand")) break;
const unitRaw = cellStr(sheet, r, 3);
const qty = cellNum(sheet, r, 4);
const unitPrice = cellNum(sheet, r, 5);
// Skip rows with no quantity and no unit price (T&C text rows, etc.)
if (qty === 0 && unitPrice === 0) continue;
const gstRaw = cellNum(sheet, r, 7);
const gstRate = gstRaw > 1 ? gstRaw / 100 : gstRaw;
lineItems.push({
description: desc,
unit: unitRaw || "pc",
quantity: qty || 1,
unitPrice,
gstRate: gstRate || 0.18,
});
}
return {
poNumber,
piQuotationNo,
placeOfDelivery,
tcDelivery,
tcDispatch,
tcInspection,
tcTransitInsurance,
tcPaymentTerms,
tcOthers,
vendorName,
vendorAddress,
vendorContact,
lineItems,
};
}
export function parseWorkbook(buffer: Buffer): ParsedImport[] {
const workbook = XLSX.read(buffer, { type: "buffer" });
const results: ParsedImport[] = [];
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
try {
const parsed = parseSheet(sheet);
if (parsed.lineItems.length > 0) results.push(parsed);
} catch {
// skip unparseable sheets
}
}
return results;
}

View file

@ -0,0 +1,159 @@
/**
* Integration tests for discardDraftPo server action.
* Verifies: ownership checks, status guard, cascade deletion, role permissions.
*/
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 { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
import {
makeSession, getSeedUser, getSeedVessel, getSeedAccount,
makePoForm, deletePosByTitle,
} from "./helpers";
const PREFIX = "INTTEST_DISCARD_";
let techId: string;
let managerId: string;
let accountsId: string;
let vesselId: string;
let accountId: string;
beforeAll(async () => {
const [tech, mgr, acct, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
getSeedVessel("MV Pelagia Star"),
getSeedAccount("TECH-OPS"),
]);
techId = tech.id;
managerId = mgr.id;
accountsId = acct.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
async function createDraft(title: string, asUserId = techId, asRole: Parameters<typeof makeSession>[1] = "TECHNICAL") {
vi.mocked(auth).mockResolvedValue(makeSession(asUserId, asRole));
const form = makePoForm({ title, vesselId, accountId, intent: "draft" });
const result = await createPo(form);
return (result as { id: string }).id;
}
// ── Happy path ────────────────────────────────────────────────────────────────
describe("discard — happy path", () => {
it("owner (TECHNICAL) can discard their own DRAFT", async () => {
const poId = await createDraft(`${PREFIX}OwnerDiscard`);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await discardDraftPo(poId);
expect(result).toEqual({ ok: true });
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).toBeNull();
});
it("MANAGER can discard any DRAFT PO (not their own)", async () => {
const poId = await createDraft(`${PREFIX}MgrDiscard`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await discardDraftPo(poId);
expect(result).toEqual({ ok: true });
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).toBeNull();
});
it("SUPERUSER can discard any DRAFT PO", async () => {
const superuser = await getSeedUser("admin@pelagia.local");
const poId = await createDraft(`${PREFIX}SuperDiscard`);
vi.mocked(auth).mockResolvedValue(makeSession(superuser.id, "SUPERUSER"));
const result = await discardDraftPo(poId);
expect(result).toEqual({ ok: true });
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).toBeNull();
});
it("removes POActions cascade-lessly (no FK violation)", async () => {
const poId = await createDraft(`${PREFIX}Cascade`);
// Verify a CREATED action exists before discard
const before = await db.pOAction.findMany({ where: { poId } });
expect(before.length).toBeGreaterThan(0);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
await discardDraftPo(poId);
const after = await db.pOAction.findMany({ where: { poId } });
expect(after).toHaveLength(0);
});
it("removes line items along with the PO", async () => {
const poId = await createDraft(`${PREFIX}LineItemCleanup`);
const linesBefore = await db.pOLineItem.findMany({ where: { poId } });
expect(linesBefore.length).toBeGreaterThan(0);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
await discardDraftPo(poId);
const linesAfter = await db.pOLineItem.findMany({ where: { poId } });
expect(linesAfter).toHaveLength(0);
});
});
// ── Permission denials ────────────────────────────────────────────────────────
describe("discard — negative / permission tests", () => {
it("returns error for unauthenticated request", async () => {
const poId = await createDraft(`${PREFIX}Unauth`);
vi.mocked(auth).mockResolvedValue(null);
expect(await discardDraftPo(poId)).toHaveProperty("error");
});
it("TECHNICAL cannot discard another user's DRAFT", async () => {
// Create PO as manager, try to discard as tech
const poId = await createDraft(`${PREFIX}WrongOwner`, managerId, "MANAGER");
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await discardDraftPo(poId);
expect(result).toHaveProperty("error");
// PO must still exist
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull();
});
it("ACCOUNTS cannot discard any PO (not in allowed roles)", async () => {
const poId = await createDraft(`${PREFIX}AccountsForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await discardDraftPo(poId);
expect(result).toHaveProperty("error");
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull();
});
it("returns error for non-existent PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await discardDraftPo("non-existent-id");
expect(result).toHaveProperty("error");
});
});
// ── Status guard ──────────────────────────────────────────────────────────────
describe("discard — status guard", () => {
it("cannot discard a submitted (MGR_REVIEW) PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Submitted`, vesselId, accountId, intent: "submit" });
const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await discardDraftPo(poId);
expect(result).toHaveProperty("error");
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("MGR_REVIEW");
});
});

View file

@ -0,0 +1,162 @@
/**
* Integration tests for POST /api/po/import.
* Tests authorization guards and end-to-end parsing of the Sample_PO.xlsx
* fixture using the real route handler.
*/
import { vi, describe, it, expect, beforeAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { auth } from "@/auth";
import { readFileSync } from "fs";
import { resolve } from "path";
import { NextRequest } from "next/server";
import { POST } from "@/app/api/po/import/route";
import { makeSession, getSeedUser } from "./helpers";
import type { ParsedImport } from "@/lib/po-import-parser";
const SAMPLE_XLSX = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
let techId: string;
let managerId: string;
let accountsId: string;
beforeAll(async () => {
const [tech, mgr, acct] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
]);
techId = tech.id;
managerId = mgr.id;
accountsId = acct.id;
});
function makeFileRequest(filePath?: string) {
const formData = new FormData();
if (filePath) {
const buffer = readFileSync(filePath);
const file = new File(
[buffer],
"import.xlsx",
{ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }
);
formData.append("file", file);
}
return new NextRequest("http://localhost/api/po/import", { method: "POST", body: formData });
}
// ── Authorization ─────────────────────────────────────────────────────────────
describe("POST /api/po/import — authorization", () => {
it("returns 401 for unauthenticated requests", async () => {
vi.mocked(auth).mockResolvedValue(null);
const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(401);
});
it("returns 403 for TECHNICAL role", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toMatch(/forbidden/i);
});
it("returns 403 for ACCOUNTS role", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(403);
});
it("returns 200 for MANAGER role with valid file", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(200);
});
});
// ── Input validation ──────────────────────────────────────────────────────────
describe("POST /api/po/import — input validation", () => {
beforeEach(() => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
});
it("returns 400 when no file is provided", async () => {
const res = await POST(makeFileRequest());
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBeDefined();
});
it("returns 400 for a non-XLSX binary file", async () => {
const formData = new FormData();
const garbage = new File([Buffer.from("not-an-xlsx")], "bad.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
formData.append("file", garbage);
const req = new NextRequest("http://localhost/api/po/import", { method: "POST", body: formData });
const res = await POST(req);
expect(res.status).toBe(400);
});
});
// ── Parsing results ───────────────────────────────────────────────────────────
describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
let results: ParsedImport[];
beforeAll(async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const res = await POST(makeFileRequest(SAMPLE_XLSX));
const data = await res.json();
results = data.results;
});
it("returns exactly one result (one sheet with PO data)", () => {
expect(results).toHaveLength(1);
});
it("extracted line items contain no T&C rows", () => {
const items = results[0].lineItems;
const hasTcText = items.some(
(li) =>
li.description.toLowerCase().includes("please quote") ||
li.description.toLowerCase().includes("delivery :") ||
li.description.toLowerCase().includes("payment terms")
);
expect(hasTcText).toBe(false);
});
it("extracted exactly one line item", () => {
expect(results[0].lineItems).toHaveLength(1);
});
it("line item has correct description", () => {
expect(results[0].lineItems[0].description).toBe("Eni EP 80W90 GEAR OIL");
});
it("line item has correct quantity (1050)", () => {
expect(results[0].lineItems[0].quantity).toBe(1050);
});
it("line item has correct unit price (182)", () => {
expect(results[0].lineItems[0].unitPrice).toBe(182);
});
it("line item has GST rate 0.18", () => {
expect(results[0].lineItems[0].gstRate).toBeCloseTo(0.18);
});
it("vendor name extracted correctly", () => {
expect(results[0].vendorName).toBe("Apar Industries Ltd");
});
it("PI quotation number extracted", () => {
expect(results[0].piQuotationNo).toBe("Verbal");
});
it("delivery T&C stripped of prefix", () => {
expect(results[0].tcDelivery).not.toMatch(/^DELIVERY\s*:/i);
expect(results[0].tcDelivery).toMatch(/4 to 5 days/i);
});
});

View file

@ -0,0 +1,127 @@
/**
* Integration tests for MANAGER role creating and submitting POs.
* Verifies the new create_po / submit_po permissions granted to MANAGER.
*/
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 { approvepo } from "@/app/(portal)/approvals/[id]/actions";
import { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
import {
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
makePoForm, deletePosByTitle,
} from "./helpers";
const PREFIX = "INTTEST_MGR_CREATE_";
let managerId: string;
let accountsId: string;
let vesselId: string;
let accountId: string;
let vendorId: string;
beforeAll(async () => {
const [mgr, acct, vessel, account, vendor] = await Promise.all([
getSeedUser("manager@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
getSeedVessel("MV Pelagia Star"),
getSeedAccount("TECH-OPS"),
getSeedVendor("Apar Industries Ltd"),
]);
managerId = mgr.id;
accountsId = acct.id;
vesselId = vessel.id;
accountId = account.id;
vendorId = vendor.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
// ── MANAGER can create POs ────────────────────────────────────────────────────
describe("MANAGER — create PO", () => {
it("MANAGER can save a PO as DRAFT", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" });
const result = await createPo(form);
expect(result).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUnique({ where: { id: (result as { id: string }).id } });
expect(po?.status).toBe("DRAFT");
expect(po?.submitterId).toBe(managerId);
});
it("MANAGER can submit a PO directly", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
const result = await createPo(form);
expect(result).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUnique({ where: { id: (result as { id: string }).id } });
expect(po?.status).toBe("MGR_REVIEW");
expect(po?.submittedAt).not.toBeNull();
});
it("MANAGER can discard their own DRAFT", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}Discard`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string };
const result = await discardDraftPo(poId);
expect(result).toEqual({ ok: true });
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).toBeNull();
});
it("stores correct submitterId on MANAGER-created PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}SubmitterId`, vesselId, accountId });
const { id: poId } = (await createPo(form)) as { id: string };
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.submitterId).toBe(managerId);
});
});
// ── Negative permission tests ─────────────────────────────────────────────────
describe("role — negative permission tests for PO creation", () => {
it("ACCOUNTS cannot create a PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const form = makePoForm({ title: `${PREFIX}AcctsForbidden`, vesselId, accountId });
const result = await createPo(form);
expect(result).toHaveProperty("error");
});
it("unauthenticated request returns Unauthorized", 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("MANAGER cannot approve their own submitted PO (same user)", async () => {
// Manager creates and submits
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({
title: `${PREFIX}SelfApprove`,
vesselId,
accountId,
vendorId,
intent: "submit",
});
const { id: poId } = (await createPo(form)) as { id: string };
// Approving as the same manager — the action itself doesn't block same-user approval
// because approval authority is role-based, not submitter-based.
// This test documents the current behaviour.
const result = await approvepo({ poId });
// Should succeed because MANAGER has approve_po permission and the PO has a vendor
expect(result).toEqual({ ok: true });
});
});

View file

@ -0,0 +1,145 @@
/**
* Integration tests for GET /api/products/search.
* Tests authorization, query validation, filtering, and Decimal serialisation.
*/
import { vi, describe, it, expect, beforeAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { auth } from "@/auth";
import { NextRequest } from "next/server";
import { GET } from "@/app/api/products/search/route";
import { makeSession, getSeedUser } from "./helpers";
let techId: string;
let accountsId: string;
beforeAll(async () => {
const [tech, acct] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
]);
techId = tech.id;
accountsId = acct.id;
});
function makeRequest(query: string) {
return new NextRequest(`http://localhost/api/products/search?q=${encodeURIComponent(query)}`);
}
// ── Authorization ─────────────────────────────────────────────────────────────
describe("GET /api/products/search — authorization", () => {
it("returns 401 for unauthenticated requests", async () => {
vi.mocked(auth).mockResolvedValue(null);
const res = await GET(makeRequest("oil"));
expect(res.status).toBe(401);
});
it("TECHNICAL can search products", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const res = await GET(makeRequest("oil"));
expect(res.status).toBe(200);
});
it("ACCOUNTS can search products", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const res = await GET(makeRequest("oil"));
expect(res.status).toBe(200);
});
});
// ── Query validation ──────────────────────────────────────────────────────────
describe("GET /api/products/search — query validation", () => {
beforeEach(() => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
});
it("returns empty array for query shorter than 2 chars", async () => {
const res = await GET(makeRequest("a"));
const data = await res.json();
expect(data).toEqual([]);
});
it("returns empty array for empty query", async () => {
const res = await GET(makeRequest(""));
const data = await res.json();
expect(data).toEqual([]);
});
it("returns results for query of exactly 2 chars", async () => {
const res = await GET(makeRequest("oi"));
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});
});
// ── Search behaviour ──────────────────────────────────────────────────────────
describe("GET /api/products/search — search behaviour", () => {
beforeEach(() => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
});
it("finds products by name substring", async () => {
const res = await GET(makeRequest("Gear Oil"));
const data: { name: string }[] = await res.json();
expect(data.some((p) => p.name.toLowerCase().includes("gear oil"))).toBe(true);
});
it("finds products by product code", async () => {
const res = await GET(makeRequest("LUBE"));
const data: { code: string }[] = await res.json();
expect(data.every((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
});
it("finds products by description text", async () => {
const res = await GET(makeRequest("turbocharger"));
const data: { description: string | null }[] = await res.json();
expect(data.length).toBeGreaterThan(0);
expect(data.some((p) => p.description?.toLowerCase().includes("turbocharger"))).toBe(true);
});
it("search is case-insensitive", async () => {
const [upper, lower] = await Promise.all([
GET(makeRequest("GEAR OIL")).then((r) => r.json()),
GET(makeRequest("gear oil")).then((r) => r.json()),
]);
expect(upper.length).toBe(lower.length);
});
it("returns at most 10 results", async () => {
// Query a broad term likely to match many products
const res = await GET(makeRequest("a"));
const data: unknown[] = await res.json();
expect(data.length).toBeLessThanOrEqual(10);
});
it("serialises lastPrice as a plain number, not a Decimal object", async () => {
const res = await GET(makeRequest("Gear Oil"));
const data: { lastPrice: unknown }[] = await res.json();
const withPrice = data.find((p) => p.lastPrice !== null);
if (withPrice) {
expect(typeof withPrice.lastPrice).toBe("number");
}
});
it("excludes inactive products from results", async () => {
const { db } = await import("@/lib/db");
// Deactivate a known product temporarily
const product = await db.product.findFirst({
where: { code: "LUBE-EP80W90", isActive: true },
});
if (!product) return;
await db.product.update({ where: { id: product.id }, data: { isActive: false } });
try {
const res = await GET(makeRequest("EP 80W90"));
const data: { code: string }[] = await res.json();
expect(data.find((p) => p.code === "LUBE-EP80W90")).toBeUndefined();
} finally {
await db.product.update({ where: { id: product.id }, data: { isActive: true } });
}
});
});

View file

@ -0,0 +1,167 @@
/**
* 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, 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("TECH-OPS"),
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;
}
// Grab an unverified vendor
const unverified = await db.vendor.findFirst({ where: { isVerified: false } });
unverifiedVendorDbId = unverified!.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
async function makeReviewPo(title: string, withVendor = false) {
vi.mocked(auth).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).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).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).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).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).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).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).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
expect(result).toHaveProperty("error");
});
});

View file

@ -3,6 +3,8 @@ import { hasPermission, requirePermission } from "@/lib/permissions";
describe("Permissions", () => {
describe("hasPermission", () => {
// ── Original cases ─────────────────────────────────────────────────────
it("TECHNICAL can create POs", () => {
expect(hasPermission("TECHNICAL", "create_po")).toBe(true);
});
@ -23,10 +25,6 @@ describe("Permissions", () => {
expect(hasPermission("ACCOUNTS", "process_payment")).toBe(true);
});
it("ACCOUNTS cannot create POs", () => {
expect(hasPermission("ACCOUNTS", "create_po")).toBe(false);
});
it("SUPERUSER has all operational permissions", () => {
expect(hasPermission("SUPERUSER", "create_po")).toBe(true);
expect(hasPermission("SUPERUSER", "approve_po")).toBe(true);
@ -43,6 +41,60 @@ describe("Permissions", () => {
expect(hasPermission("AUDITOR", "approve_po")).toBe(false);
expect(hasPermission("AUDITOR", "create_po")).toBe(false);
});
// ── New permissions: MANAGER and ACCOUNTS expansions ──────────────────
it("MANAGER can create POs", () => {
expect(hasPermission("MANAGER", "create_po")).toBe(true);
});
it("MANAGER can submit POs", () => {
expect(hasPermission("MANAGER", "submit_po")).toBe(true);
});
it("MANAGER can manage vendors", () => {
expect(hasPermission("MANAGER", "manage_vendors")).toBe(true);
});
it("ACCOUNTS can manage vendors", () => {
expect(hasPermission("ACCOUNTS", "manage_vendors")).toBe(true);
});
it("ACCOUNTS cannot create POs", () => {
expect(hasPermission("ACCOUNTS", "create_po")).toBe(false);
});
it("ACCOUNTS cannot approve POs", () => {
expect(hasPermission("ACCOUNTS", "approve_po")).toBe(false);
});
it("TECHNICAL cannot manage vendors", () => {
expect(hasPermission("TECHNICAL", "manage_vendors")).toBe(false);
});
it("MANNING cannot manage vendors", () => {
expect(hasPermission("MANNING", "manage_vendors")).toBe(false);
});
it("AUDITOR cannot create, submit, or approve POs", () => {
expect(hasPermission("AUDITOR", "create_po")).toBe(false);
expect(hasPermission("AUDITOR", "submit_po")).toBe(false);
expect(hasPermission("AUDITOR", "approve_po")).toBe(false);
});
it("AUDITOR cannot manage vendors or products", () => {
expect(hasPermission("AUDITOR", "manage_vendors")).toBe(false);
expect(hasPermission("AUDITOR", "manage_products")).toBe(false);
});
it("ADMIN cannot approve or process payments", () => {
expect(hasPermission("ADMIN", "approve_po")).toBe(false);
expect(hasPermission("ADMIN", "process_payment")).toBe(false);
});
it("SUPERUSER does not have manage_vendors (admin-only permission)", () => {
expect(hasPermission("SUPERUSER", "manage_vendors")).toBe(false);
});
});
describe("requirePermission", () => {
@ -53,5 +105,9 @@ describe("Permissions", () => {
it("throws when permission is denied", () => {
expect(() => requirePermission("TECHNICAL", "approve_po")).toThrow();
});
it("throws with a message containing the role name", () => {
expect(() => requirePermission("ACCOUNTS", "approve_po")).toThrow(/ACCOUNTS/);
});
});
});

View file

@ -0,0 +1,276 @@
/**
* Unit tests for the PO Excel import parser.
* Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic
* workbooks built in-memory, without any HTTP or database layer.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import * as XLSX from "xlsx";
import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser";
const SAMPLE_PATH = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
// ── helpers ───────────────────────────────────────────────────────────────────
/** Build a minimal XLSX WorkSheet from a 2-D array of values. */
function sheetFrom(rows: (string | number | null)[][]): XLSX.WorkSheet {
return XLSX.utils.aoa_to_sheet(rows);
}
/** Build a Workbook with a single sheet. */
function wbFrom(rows: (string | number | null)[][]): XLSX.WorkBook {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, sheetFrom(rows), "Sheet1");
return wb;
}
function wbBuffer(rows: (string | number | null)[][]): Buffer {
return Buffer.from(XLSX.write(wbFrom(rows), { type: "buffer", bookType: "xlsx" }));
}
// ── cellStr / cellNum ─────────────────────────────────────────────────────────
describe("cellStr", () => {
it("returns the string value of a cell", () => {
const sheet = sheetFrom([["hello", 42, null]]);
expect(cellStr(sheet, 0, 0)).toBe("hello");
});
it("coerces a number to string", () => {
const sheet = sheetFrom([[null, 99]]);
expect(cellStr(sheet, 0, 1)).toBe("99");
});
it("returns empty string for a missing cell", () => {
const sheet = sheetFrom([[]]);
expect(cellStr(sheet, 5, 5)).toBe("");
});
it("trims whitespace", () => {
const sheet = sheetFrom([[" padded "]]);
expect(cellStr(sheet, 0, 0)).toBe("padded");
});
});
describe("cellNum", () => {
it("returns the numeric value of a cell", () => {
const sheet = sheetFrom([[42]]);
expect(cellNum(sheet, 0, 0)).toBe(42);
});
it("returns 0 for a missing cell", () => {
const sheet = sheetFrom([[]]);
expect(cellNum(sheet, 0, 5)).toBe(0);
});
it("returns 0 for a non-numeric string cell", () => {
const sheet = sheetFrom([["text"]]);
expect(cellNum(sheet, 0, 0)).toBe(0);
});
it("parses a numeric string", () => {
const sheet = sheetFrom([["182"]]);
expect(cellNum(sheet, 0, 0)).toBe(182);
});
});
// ── parseSheet against real Sample_PO.xlsx ───────────────────────────────────
describe("parseSheet — Sample_PO.xlsx", () => {
let parsed: ReturnType<typeof parseSheet>;
beforeAll(() => {
const buffer = readFileSync(SAMPLE_PATH);
const wb = XLSX.read(buffer, { type: "buffer" });
parsed = parseSheet(wb.Sheets[wb.SheetNames[0]]);
});
it("extracts exactly one line item (T&C rows must not appear)", () => {
expect(parsed.lineItems).toHaveLength(1);
});
it("extracts the correct line item description", () => {
expect(parsed.lineItems[0].description).toBe("Eni EP 80W90 GEAR OIL");
});
it("extracts quantity 1050", () => {
expect(parsed.lineItems[0].quantity).toBe(1050);
});
it("extracts unit price 182", () => {
expect(parsed.lineItems[0].unitPrice).toBe(182);
});
it("extracts unit 'Ltr'", () => {
expect(parsed.lineItems[0].unit).toBe("Ltr");
});
it("extracts GST rate 0.18 (raw value already <1)", () => {
expect(parsed.lineItems[0].gstRate).toBeCloseTo(0.18);
});
it("extracts vendor name", () => {
expect(parsed.vendorName).toBe("Apar Industries Ltd");
});
it("extracts PI/Quotation number", () => {
expect(parsed.piQuotationNo).toBe("Verbal");
});
it("extracts PO number", () => {
expect(parsed.poNumber).toMatch(/PMS\/HNR3\/056/);
});
it("extracts delivery T&C (strips 'DELIVERY :' prefix)", () => {
expect(parsed.tcDelivery).toMatch(/Within 4 to 5 days/i);
});
it("extracts payment terms T&C (strips 'PAYMENT TERMS:' prefix)", () => {
expect(parsed.tcPaymentTerms).toMatch(/30 days/i);
});
it("extracts place of delivery", () => {
expect(parsed.placeOfDelivery).toMatch(/CBD Belapur/i);
});
});
// ── parseSheet — synthetic cases ──────────────────────────────────────────────
describe("parseSheet — synthetic edge cases", () => {
/** Build a minimal valid sheet: header row at 14, one item at 15. */
function buildSheet(overrides: Partial<{
snRow15: string | number;
descRow15: string;
unitRow15: string;
qtyRow15: number;
priceRow15: number;
gstRow15: number;
extraRows: (string | number | null)[][];
}> = {}) {
const rows: (string | number | null)[][] = Array.from({ length: 40 }, () =>
Array(10).fill(null)
);
// Row 4: PO number
rows[4][2] = "TEST-PO-001";
// Row 5: PI quotation
rows[5][2] = "INV-999";
// Row 8: delivery address
rows[8][2] = "Test Delivery Address";
// Row 12: vendor
rows[12][2] = "Test Vendor";
// Row 14: header
rows[14] = ["S.N.", "Description", null, "Unit", "Qnty", "Unit price", "Taxable", "GST%", "Total"];
// Row 15: one line item
rows[15][0] = overrides.snRow15 ?? 1;
rows[15][1] = overrides.descRow15 ?? "Test Item";
rows[15][3] = overrides.unitRow15 ?? "pc";
rows[15][4] = overrides.qtyRow15 ?? 5;
rows[15][5] = overrides.priceRow15 ?? 100;
rows[15][7] = overrides.gstRow15 ?? 0.18;
// Extra rows appended after row 15
if (overrides.extraRows) {
overrides.extraRows.forEach((row, i) => {
rows[16 + i] = row.concat(Array(10).fill(null)).slice(0, 10);
});
}
return sheetFrom(rows);
}
it("parses a single synthetic line item correctly", () => {
const result = parseSheet(buildSheet());
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].description).toBe("Test Item");
expect(result.lineItems[0].quantity).toBe(5);
expect(result.lineItems[0].unitPrice).toBe(100);
});
it("converts a GST rate > 1 (percentage) to a fraction", () => {
const result = parseSheet(buildSheet({ gstRow15: 18 }));
expect(result.lineItems[0].gstRate).toBeCloseTo(0.18);
});
it("leaves a GST rate already < 1 unchanged", () => {
const result = parseSheet(buildSheet({ gstRow15: 0.05 }));
expect(result.lineItems[0].gstRate).toBeCloseTo(0.05);
});
it("falls back to gstRate 0.18 when GST cell is 0", () => {
const result = parseSheet(buildSheet({ gstRow15: 0 }));
expect(result.lineItems[0].gstRate).toBeCloseTo(0.18);
});
it("falls back to unit 'pc' when unit cell is empty", () => {
const result = parseSheet(buildSheet({ unitRow15: "" }));
expect(result.lineItems[0].unit).toBe("pc");
});
it("stops at INSTRUCTIONS TO VENDORS row — T&C rows not included", () => {
const result = parseSheet(buildSheet({
extraRows: [
// row 16: "INSTRUCTIONS TO VENDORS" in col 0
["INSTRUCTIONS TO VENDORS", null, null, null, null, null, null, null, null, null],
// row 17: T&C item — must NOT appear in lineItems
[1, "Please quote this PO number in all correspondence", null, null, null, null, null, null, null, null],
[2, "DELIVERY : Within 4 days", null, null, null, null, null, null, null, null],
],
}));
expect(result.lineItems).toHaveLength(1);
});
it("skips rows where both qty and unitPrice are 0 (text-only rows)", () => {
const result = parseSheet(buildSheet({
extraRows: [
[2, "Some descriptive text but no price", null, null, 0, 0, null, null, null, null],
],
}));
expect(result.lineItems).toHaveLength(1);
});
it("skips fully empty rows", () => {
const result = parseSheet(buildSheet({
extraRows: [
[null, null, null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null, null, null],
[2, "Second Item", null, "set", 3, 500, 1500, 0.18, 1770, null],
],
}));
expect(result.lineItems).toHaveLength(2);
expect(result.lineItems[1].description).toBe("Second Item");
});
it("returns empty lineItems for a blank sheet", () => {
const result = parseSheet(sheetFrom([]));
expect(result.lineItems).toHaveLength(0);
});
});
// ── parseWorkbook ─────────────────────────────────────────────────────────────
describe("parseWorkbook", () => {
it("parses the real Sample_PO.xlsx and returns one result", () => {
const buffer = readFileSync(SAMPLE_PATH);
const results = parseWorkbook(buffer);
expect(results).toHaveLength(1);
expect(results[0].lineItems).toHaveLength(1);
});
it("returns empty array for a workbook with no parseable PO data", () => {
const buffer = wbBuffer([["Header only"], ["no PO data here"]]);
const results = parseWorkbook(buffer);
expect(results).toHaveLength(0);
});
it("returns empty array for a non-XLSX buffer (errors are swallowed)", () => {
// parseWorkbook catches XLSX.read errors — callers must check the result length
let result: ReturnType<typeof parseWorkbook>;
try {
result = parseWorkbook(Buffer.from("not-an-xlsx-file"));
} catch {
// Some xlsx versions throw, others return garbage — both are acceptable
result = [];
}
// Either it returned [] or threw; in both cases no PO data should be present
expect(Array.isArray(result) ? result : []).toHaveLength(0);
});
});

View file

@ -108,5 +108,57 @@ describe("PO State Machine", () => {
const actions = getAvailableActions("CLOSED", "MANAGER");
expect(actions).toHaveLength(0);
});
// Role expansions added in feat: manager PO creation
it("returns submit for MANAGER on DRAFT", () => {
expect(getAvailableActions("DRAFT", "MANAGER")).toContain("submit");
});
it("returns submit for MANAGER on EDITS_REQUESTED", () => {
expect(getAvailableActions("EDITS_REQUESTED", "MANAGER")).toContain("submit");
});
it("returns provide_vendor_id for ACCOUNTS on VENDOR_ID_PENDING", () => {
expect(getAvailableActions("VENDOR_ID_PENDING", "ACCOUNTS")).toContain("provide_vendor_id");
});
it("still returns no submit for ACCOUNTS on DRAFT", () => {
expect(getAvailableActions("DRAFT", "ACCOUNTS")).not.toContain("submit");
});
it("AUDITOR has no available actions on any status", () => {
for (const status of ["DRAFT", "MGR_REVIEW", "VENDOR_ID_PENDING", "MGR_APPROVED"] as const) {
expect(getAvailableActions(status, "AUDITOR")).toHaveLength(0);
}
});
});
describe("canPerformAction — new role expansions", () => {
it("MANAGER can submit from DRAFT", () => {
expect(canPerformAction("DRAFT", "submit", "MANAGER")).toBe(true);
});
it("MANAGER can submit from EDITS_REQUESTED", () => {
expect(canPerformAction("EDITS_REQUESTED", "submit", "MANAGER")).toBe(true);
});
it("ACCOUNTS can provide_vendor_id from VENDOR_ID_PENDING", () => {
expect(canPerformAction("VENDOR_ID_PENDING", "provide_vendor_id", "ACCOUNTS")).toBe(true);
});
it("ACCOUNTS still cannot submit from DRAFT", () => {
expect(canPerformAction("DRAFT", "submit", "ACCOUNTS")).toBe(false);
});
it("AUDITOR cannot perform any action", () => {
expect(canPerformAction("DRAFT", "submit", "AUDITOR")).toBe(false);
expect(canPerformAction("MGR_REVIEW", "approve", "AUDITOR")).toBe(false);
});
it("ADMIN cannot perform any PO state transitions", () => {
expect(canPerformAction("DRAFT", "submit", "ADMIN")).toBe(false);
expect(canPerformAction("MGR_REVIEW", "approve", "ADMIN")).toBe(false);
expect(canPerformAction("MGR_APPROVED", "process_payment", "ADMIN")).toBe(false);
});
});
});