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