pelagia-portal/App/tests/unit/po-line-items-editor.test.tsx
Hardik b43d44b59a 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>
2026-05-30 18:50:23 +05:30

202 lines
9.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import type { LineItemInput } from "@/lib/validations/po";
const DEFAULT_ITEM: LineItemInput = {
name: "Bearing Assembly",
description: "Test Item",
quantity: 10,
unit: "pc",
unitPrice: 100,
gstRate: 0.18,
};
// ── Render (edit mode) ────────────────────────────────────────────────────────
describe("LineItemsEditor — edit mode", () => {
it("renders one row by default when one item provided", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
// Each row has a description input
expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(1);
});
it("shows the initial description value", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const input = screen.getByPlaceholderText("Description (optional)") as HTMLInputElement;
expect(input.value).toBe("Test Item");
});
it("shows the initial quantity value", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const inputs = screen.getAllByRole("spinbutton");
expect(inputs[0].getAttribute("value") ?? (inputs[0] as HTMLInputElement).value).toBe("10");
});
it("shows 18% as the default GST rate", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
const gstSelect = selects.find((s) => s.value === "0.18");
expect(gstSelect).toBeTruthy();
expect(gstSelect!.value).toBe("0.18");
});
it("disables the remove button when only one row exists", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const removeBtn = screen.getByRole("button", { name: /delete|remove|trash/i });
expect(removeBtn).toBeDisabled();
});
it("calls onChange when description is changed", async () => {
const onChange = vi.fn();
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
const input = screen.getByPlaceholderText("Description (optional)");
await userEvent.clear(input);
await userEvent.type(input, "Gear Oil");
expect(onChange).toHaveBeenCalled();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
expect(lastCall[0].description).toBe("Gear Oil");
});
it("adds a row when 'Add line item' is clicked", async () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const addBtn = screen.getByRole("button", { name: /add line item/i });
await userEvent.click(addBtn);
expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(2);
});
it("enables remove button after a second row is added", async () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: /add line item/i }));
const removeBtns = screen.getAllByRole("button", { name: /delete|remove|trash/i });
removeBtns.forEach((btn) => expect(btn).not.toBeDisabled());
});
it("removes a row when delete is clicked (with 2 rows)", async () => {
const items: LineItemInput[] = [
{ ...DEFAULT_ITEM, description: "Item A" },
{ ...DEFAULT_ITEM, description: "Item B" },
];
render(<LineItemsEditor items={items} onChange={vi.fn()} />);
const removeBtns = screen.getAllByRole("button", { name: /delete|remove|trash/i });
await userEvent.click(removeBtns[0]);
expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(1);
});
it("calls onChange with updated gstRate when GST dropdown changes", async () => {
const onChange = vi.fn();
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
const gstSelect = selects.find((s) => s.value === "0.18")!;
fireEvent.change(gstSelect, { target: { value: "0.05" } });
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
expect(lastCall[0].gstRate).toBeCloseTo(0.05);
});
});
// ── Totals calculation (edit mode) ────────────────────────────────────────────
describe("LineItemsEditor — totals calculation", () => {
it("shows correct taxable subtotal (qty × unit price)", () => {
// qty=10, unitPrice=100 → taxable=1000
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
expect(screen.getByText(/Taxable subtotal/i)).toBeInTheDocument();
// Should show 1,000 somewhere in the footer
const text = document.body.textContent ?? "";
expect(text).toMatch(/1[,.]?000/);
});
it("shows correct GST amount (taxable × gstRate)", () => {
// 1000 × 0.18 = 180
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
expect(screen.getByText(/^GST$/i)).toBeInTheDocument();
const text = document.body.textContent ?? "";
expect(text).toMatch(/180/);
});
it("shows correct grand total (taxable + GST)", () => {
// 1000 + 180 = 1180
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
expect(screen.getByText(/Grand Total/i)).toBeInTheDocument();
const text = document.body.textContent ?? "";
expect(text).toMatch(/1[,.]?180/);
});
it("sums multiple line items correctly", () => {
const items: LineItemInput[] = [
{ name: "Item A", description: "Item A", quantity: 5, unit: "pc", unitPrice: 100, 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
render(<LineItemsEditor items={items} onChange={vi.fn()} />);
const text = document.body.textContent ?? "";
expect(text).toMatch(/1[,.]?000/); // taxable
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 ────────────────────────────────────────────────────────────
describe("LineItemsEditor — read-only mode", () => {
it("renders without input fields", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
expect(screen.queryByPlaceholderText("Item description")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add line item/i })).not.toBeInTheDocument();
});
it("displays the description as text", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
expect(screen.getByText("Test Item")).toBeInTheDocument();
});
it("shows GST% column in read-only mode", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
expect(screen.getByText("18%")).toBeInTheDocument();
});
it("shows taxable, GST, grand total rows in read-only footer", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
expect(screen.getByText(/Taxable subtotal/i)).toBeInTheDocument();
expect(screen.getByText(/^GST$/i)).toBeInTheDocument();
expect(screen.getByText(/Grand Total/i)).toBeInTheDocument();
});
it("shows manager-amended diff banner when originalItems provided", () => {
const original: LineItemInput[] = [{ ...DEFAULT_ITEM, unitPrice: 80 }];
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly originalItems={original} />);
expect(screen.getByText(/amended by manager/i)).toBeInTheDocument();
});
it("shows strikethrough on changed unit price when diff available", () => {
const original: LineItemInput[] = [{ ...DEFAULT_ITEM, unitPrice: 80 }];
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly originalItems={original} />);
// The original price 80 should appear with line-through styling
const text = document.body.textContent ?? "";
expect(text).toMatch(/80/);
expect(text).toMatch(/100/);
});
});