fix(seed+tests): correct vessel/cost-centre names, update unit tests
seed-prod.ts: - Correct vessel list: Head Office, PMS Kochi, CSD H&R 1/3/4, CSD Champion, CSD Hanuman, Kavaratti, Laccadives, Thinnakara, Thillaakam, GD3000 - Add System Admin user (admin@pelagia.local / admin1234) via bcrypt Unit tests: - po-import-parser: assert on line item .name rather than .description - po-line-items-editor: fix placeholder text assertions, add .name to LineItemInput fixtures, add two new GST 0% calculation tests - validations: add .name to line item fixtures; update createPoSchema assertions to reference costCentreRef; mark description as optional Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
280966a369
commit
b43d44b59a
4 changed files with 77 additions and 31 deletions
|
|
@ -13,6 +13,9 @@
|
||||||
|
|
||||||
import { PrismaClient, Role } from "@prisma/client";
|
import { PrismaClient, Role } from "@prisma/client";
|
||||||
import { ACCOUNTING_CODES } from "./accounting-codes-data";
|
import { ACCOUNTING_CODES } from "./accounting-codes-data";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const hash = (p: string) => bcrypt.hash(p, 12);
|
||||||
|
|
||||||
const db = new PrismaClient();
|
const db = new PrismaClient();
|
||||||
|
|
||||||
|
|
@ -50,16 +53,18 @@ const SITES: { code: string; name: string }[] = [
|
||||||
// ─── Vessels (code, name, site code) ─────────────────────────────────────────
|
// ─── Vessels (code, name, site code) ─────────────────────────────────────────
|
||||||
|
|
||||||
const VESSELS: { code: string; name: string }[] = [
|
const VESSELS: { code: string; name: string }[] = [
|
||||||
{ code: "HNR1", name: "HNR 1" },
|
{ name: "Head Office", code: "HOFC" },
|
||||||
{ code: "HNR2", name: "HNR 2" },
|
{ name: "CSD PMS KOCHI", code: "PMSK" },
|
||||||
{ code: "HNR3", name: "HNR 3" },
|
{ name: "CSD H&R 1", code: "HNR1" },
|
||||||
{ code: "HNR4", name: "HNR 4" },
|
{ name: "CSD H&R 3", code: "HNR3" },
|
||||||
{ code: "CHAMPION", name: "Champion" },
|
{ name: "CSD H&R 4", code: "HNR4" },
|
||||||
{ code: "HANUNAM", name: "Hanunam" },
|
{ name: "CSD CHAMPION", code: "CHMP" },
|
||||||
{ code: "SEJAL", name: "Sejal" },
|
{ name: "CSD HANUMAN", code: "HANU" },
|
||||||
{ code: "SEJAL2", name: "Sejal 2" },
|
{ name: "KAVARATTI", code: "KVRT" },
|
||||||
{ code: "GD3000", name: "GD 3000" },
|
{ name: "LACCADIVES", code: "LACD" },
|
||||||
{ code: "THILAKKAM", name: "Thilakkam" },
|
{ name: "THINNAKARA", code: "THNK" },
|
||||||
|
{ name: "THILLAAKAM", code: "THKM" },
|
||||||
|
{ name: "GD3000", code: "GD30" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -84,6 +89,12 @@ async function main() {
|
||||||
console.log(` ✓ ${u.name} <${u.email}> [${u.role}]`);
|
console.log(` ✓ ${u.name} <${u.email}> [${u.role}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const admin = await db.user.upsert({
|
||||||
|
where: { email: "admin@pelagia.local" },
|
||||||
|
update: {},
|
||||||
|
create: { employeeId: "ADM-001", email: "admin@pelagia.local", name: "System Admin", passwordHash: await hash("admin1234"), role: Role.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
// ── Sites ──────────────────────────────────────────────────────────────────
|
// ── Sites ──────────────────────────────────────────────────────────────────
|
||||||
console.log("\n📍 Seeding sites…");
|
console.log("\n📍 Seeding sites…");
|
||||||
for (const s of SITES) {
|
for (const s of SITES) {
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,8 @@ describe("parseSheet — Sample_PO.xlsx", () => {
|
||||||
expect(parsed.lineItems).toHaveLength(1);
|
expect(parsed.lineItems).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts the correct line item description", () => {
|
it("extracts the correct line item name", () => {
|
||||||
expect(parsed.lineItems[0].description).toBe("Eni EP 80W90 GEAR OIL");
|
expect(parsed.lineItems[0].name).toBe("Eni EP 80W90 GEAR OIL");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts quantity 1050", () => {
|
it("extracts quantity 1050", () => {
|
||||||
|
|
@ -180,7 +180,7 @@ describe("parseSheet — synthetic edge cases", () => {
|
||||||
it("parses a single synthetic line item correctly", () => {
|
it("parses a single synthetic line item correctly", () => {
|
||||||
const result = parseSheet(buildSheet());
|
const result = parseSheet(buildSheet());
|
||||||
expect(result.lineItems).toHaveLength(1);
|
expect(result.lineItems).toHaveLength(1);
|
||||||
expect(result.lineItems[0].description).toBe("Test Item");
|
expect(result.lineItems[0].name).toBe("Test Item");
|
||||||
expect(result.lineItems[0].quantity).toBe(5);
|
expect(result.lineItems[0].quantity).toBe(5);
|
||||||
expect(result.lineItems[0].unitPrice).toBe(100);
|
expect(result.lineItems[0].unitPrice).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
@ -236,7 +236,7 @@ describe("parseSheet — synthetic edge cases", () => {
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
expect(result.lineItems).toHaveLength(2);
|
expect(result.lineItems).toHaveLength(2);
|
||||||
expect(result.lineItems[1].description).toBe("Second Item");
|
expect(result.lineItems[1].name).toBe("Second Item");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty lineItems for a blank sheet", () => {
|
it("returns empty lineItems for a blank sheet", () => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
||||||
const DEFAULT_ITEM: LineItemInput = {
|
const DEFAULT_ITEM: LineItemInput = {
|
||||||
|
name: "Bearing Assembly",
|
||||||
description: "Test Item",
|
description: "Test Item",
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
unit: "pc",
|
unit: "pc",
|
||||||
|
|
@ -18,12 +19,12 @@ describe("LineItemsEditor — edit mode", () => {
|
||||||
it("renders one row by default when one item provided", () => {
|
it("renders one row by default when one item provided", () => {
|
||||||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||||||
// Each row has a description input
|
// Each row has a description input
|
||||||
expect(screen.getAllByPlaceholderText("Item description")).toHaveLength(1);
|
expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the initial description value", () => {
|
it("shows the initial description value", () => {
|
||||||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||||||
const input = screen.getByPlaceholderText("Item description") as HTMLInputElement;
|
const input = screen.getByPlaceholderText("Description (optional)") as HTMLInputElement;
|
||||||
expect(input.value).toBe("Test Item");
|
expect(input.value).toBe("Test Item");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -50,7 +51,7 @@ describe("LineItemsEditor — edit mode", () => {
|
||||||
it("calls onChange when description is changed", async () => {
|
it("calls onChange when description is changed", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
|
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
|
||||||
const input = screen.getByPlaceholderText("Item description");
|
const input = screen.getByPlaceholderText("Description (optional)");
|
||||||
await userEvent.clear(input);
|
await userEvent.clear(input);
|
||||||
await userEvent.type(input, "Gear Oil");
|
await userEvent.type(input, "Gear Oil");
|
||||||
expect(onChange).toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
|
@ -62,7 +63,7 @@ describe("LineItemsEditor — edit mode", () => {
|
||||||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||||||
const addBtn = screen.getByRole("button", { name: /add line item/i });
|
const addBtn = screen.getByRole("button", { name: /add line item/i });
|
||||||
await userEvent.click(addBtn);
|
await userEvent.click(addBtn);
|
||||||
expect(screen.getAllByPlaceholderText("Item description")).toHaveLength(2);
|
expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables remove button after a second row is added", async () => {
|
it("enables remove button after a second row is added", async () => {
|
||||||
|
|
@ -80,7 +81,7 @@ describe("LineItemsEditor — edit mode", () => {
|
||||||
render(<LineItemsEditor items={items} onChange={vi.fn()} />);
|
render(<LineItemsEditor items={items} onChange={vi.fn()} />);
|
||||||
const removeBtns = screen.getAllByRole("button", { name: /delete|remove|trash/i });
|
const removeBtns = screen.getAllByRole("button", { name: /delete|remove|trash/i });
|
||||||
await userEvent.click(removeBtns[0]);
|
await userEvent.click(removeBtns[0]);
|
||||||
expect(screen.getAllByPlaceholderText("Item description")).toHaveLength(1);
|
expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onChange with updated gstRate when GST dropdown changes", async () => {
|
it("calls onChange with updated gstRate when GST dropdown changes", async () => {
|
||||||
|
|
@ -124,8 +125,8 @@ describe("LineItemsEditor — totals calculation", () => {
|
||||||
|
|
||||||
it("sums multiple line items correctly", () => {
|
it("sums multiple line items correctly", () => {
|
||||||
const items: LineItemInput[] = [
|
const items: LineItemInput[] = [
|
||||||
{ description: "Item A", quantity: 5, unit: "pc", unitPrice: 100, gstRate: 0.18 },
|
{ name: "Item A", description: "Item A", quantity: 5, unit: "pc", unitPrice: 100, gstRate: 0.18 },
|
||||||
{ description: "Item B", quantity: 10, unit: "L", unitPrice: 50, gstRate: 0.18 },
|
{ name: "Item B", description: "Item B", quantity: 10, unit: "L", unitPrice: 50, gstRate: 0.18 },
|
||||||
];
|
];
|
||||||
// Taxable: 500 + 500 = 1000; GST: 180; Grand: 1180
|
// Taxable: 500 + 500 = 1000; GST: 180; Grand: 1180
|
||||||
render(<LineItemsEditor items={items} onChange={vi.fn()} />);
|
render(<LineItemsEditor items={items} onChange={vi.fn()} />);
|
||||||
|
|
@ -133,6 +134,29 @@ describe("LineItemsEditor — totals calculation", () => {
|
||||||
expect(text).toMatch(/1[,.]?000/); // taxable
|
expect(text).toMatch(/1[,.]?000/); // taxable
|
||||||
expect(text).toMatch(/1[,.]?180/); // grand total
|
expect(text).toMatch(/1[,.]?180/); // grand total
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows correct per-row total when GST rate is 0% (zero-rated)", () => {
|
||||||
|
// qty=1, unitPrice=1000, gstRate=0 → row total = ₹1,000.00, NOT ₹1,180.00
|
||||||
|
const item: LineItemInput = { name: "Test Item", quantity: 1, unit: "pc", unitPrice: 1000, gstRate: 0 };
|
||||||
|
render(<LineItemsEditor items={[item]} onChange={vi.fn()} />);
|
||||||
|
const text = document.body.textContent ?? "";
|
||||||
|
// Grand total should be 1000, not 1180
|
||||||
|
expect(text).toMatch(/1[,.]?000/);
|
||||||
|
expect(text).not.toMatch(/1[,.]?180/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct per-row total when GST rate is 0% after changing from 18%", async () => {
|
||||||
|
// Start with 18%, change to 0%, verify row total updates correctly
|
||||||
|
const item: LineItemInput = { name: "Test Item", quantity: 1, unit: "pc", unitPrice: 1000, gstRate: 0.18 };
|
||||||
|
render(<LineItemsEditor items={[item]} onChange={vi.fn()} />);
|
||||||
|
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||||
|
const gstSelect = selects.find((s) => s.value === "0.18")!;
|
||||||
|
fireEvent.change(gstSelect, { target: { value: "0" } });
|
||||||
|
// After changing to 0%, total should be 1000 not 1180
|
||||||
|
const text = document.body.textContent ?? "";
|
||||||
|
expect(text).not.toMatch(/1[,.]?180/);
|
||||||
|
expect(text).toMatch(/1[,.]?000/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Read-only mode ────────────────────────────────────────────────────────────
|
// ── Read-only mode ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { createPoSchema, lineItemSchema, TC_DEFAULTS, TC_FIXED_LINE } from "@/li
|
||||||
// ── lineItemSchema ────────────────────────────────────────────────────────────
|
// ── lineItemSchema ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("lineItemSchema", () => {
|
describe("lineItemSchema", () => {
|
||||||
const validItem = { description: "Gear Oil", quantity: "10", unit: "L", unitPrice: "182" };
|
const validItem = { name: "Gear Oil", description: "Gear Oil 15W40", quantity: "10", unit: "L", unitPrice: "182" };
|
||||||
|
|
||||||
it("accepts a valid line item", () => {
|
it("accepts a valid line item", () => {
|
||||||
const result = lineItemSchema.safeParse(validItem);
|
const result = lineItemSchema.safeParse(validItem);
|
||||||
|
|
@ -53,9 +53,9 @@ describe("lineItemSchema", () => {
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects missing description", () => {
|
it("accepts empty description (description is optional)", () => {
|
||||||
const result = lineItemSchema.safeParse({ ...validItem, description: "" });
|
const result = lineItemSchema.safeParse({ ...validItem, description: "" });
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("size is optional and omitted when empty", () => {
|
it("size is optional and omitted when empty", () => {
|
||||||
|
|
@ -71,9 +71,9 @@ describe("lineItemSchema", () => {
|
||||||
|
|
||||||
const baseValidPo = {
|
const baseValidPo = {
|
||||||
title: "Test Purchase Order",
|
title: "Test Purchase Order",
|
||||||
vesselId: "vessel-123",
|
costCentreRef: "v:vessel-123",
|
||||||
accountId: "account-456",
|
accountId: "account-456",
|
||||||
lineItems: [{ description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" }],
|
lineItems: [{ name: "Item A", description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("createPoSchema", () => {
|
describe("createPoSchema", () => {
|
||||||
|
|
@ -97,11 +97,21 @@ describe("createPoSchema", () => {
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects missing vesselId", () => {
|
it("rejects missing costCentreRef", () => {
|
||||||
const result = createPoSchema.safeParse({ ...baseValidPo, vesselId: "" });
|
const result = createPoSchema.safeParse({ ...baseValidPo, costCentreRef: "" });
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects invalid costCentreRef format", () => {
|
||||||
|
const result = createPoSchema.safeParse({ ...baseValidPo, costCentreRef: "invalid-id" });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts site costCentreRef (s: prefix)", () => {
|
||||||
|
const result = createPoSchema.safeParse({ ...baseValidPo, costCentreRef: "s:site-123" });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects empty lineItems array", () => {
|
it("rejects empty lineItems array", () => {
|
||||||
const result = createPoSchema.safeParse({ ...baseValidPo, lineItems: [] });
|
const result = createPoSchema.safeParse({ ...baseValidPo, lineItems: [] });
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
|
@ -111,8 +121,8 @@ describe("createPoSchema", () => {
|
||||||
const result = createPoSchema.safeParse({
|
const result = createPoSchema.safeParse({
|
||||||
...baseValidPo,
|
...baseValidPo,
|
||||||
lineItems: [
|
lineItems: [
|
||||||
{ description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" },
|
{ name: "Item A", description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" },
|
||||||
{ description: "Item B", quantity: "2", unit: "L", unitPrice: "150", gstRate: "0.12" },
|
{ name: "Item B", description: "Item B", quantity: "2", unit: "L", unitPrice: "150", gstRate: "0.12" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
@ -158,7 +168,8 @@ describe("TC_DEFAULTS", () => {
|
||||||
const keys = ["tcDelivery", "tcDispatch", "tcInspection", "tcTransitInsurance", "tcPaymentTerms", "tcOthers"];
|
const keys = ["tcDelivery", "tcDispatch", "tcInspection", "tcTransitInsurance", "tcPaymentTerms", "tcOthers"];
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
expect(TC_DEFAULTS).toHaveProperty(k);
|
expect(TC_DEFAULTS).toHaveProperty(k);
|
||||||
expect((TC_DEFAULTS as Record<string, string>)[k]).toBeTruthy();
|
// All keys must exist (tcOthers is intentionally empty string as a blank default)
|
||||||
|
expect((TC_DEFAULTS as Record<string, string>)[k]).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue