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:
parent
4a848f50cf
commit
48e1f19e58
10 changed files with 1280 additions and 124 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
120
App/pelagia-portal/lib/po-import-parser.ts
Normal file
120
App/pelagia-portal/lib/po-import-parser.ts
Normal 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 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();
|
||||
|
||||
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;
|
||||
}
|
||||
159
App/pelagia-portal/tests/integration/discard-po.test.ts
Normal file
159
App/pelagia-portal/tests/integration/discard-po.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
162
App/pelagia-portal/tests/integration/import-api.test.ts
Normal file
162
App/pelagia-portal/tests/integration/import-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
127
App/pelagia-portal/tests/integration/manager-po-creation.test.ts
Normal file
127
App/pelagia-portal/tests/integration/manager-po-creation.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
145
App/pelagia-portal/tests/integration/products-search.test.ts
Normal file
145
App/pelagia-portal/tests/integration/products-search.test.ts
Normal 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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
167
App/pelagia-portal/tests/integration/vendor-approval.test.ts
Normal file
167
App/pelagia-portal/tests/integration/vendor-approval.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
276
App/pelagia-portal/tests/unit/po-import-parser.test.ts
Normal file
276
App/pelagia-portal/tests/unit/po-import-parser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue