pelagia-portal/App/tests/unit/po-import-parser.test.ts
2026-05-18 23:18:58 +05:30

276 lines
9.7 KiB
TypeScript

/**
* 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);
});
});