test: unit, integration and E2E test suite (110 unit tests passing)

Unit (Vitest + jsdom):
  po-state-machine.test.ts   21 tests — all transitions and helpers
  permissions.test.ts        11 tests — all 7 roles
  utils.test.ts              17 tests — formatCurrency INR, formatDate, status labels
  validations.test.ts        24 tests — createPoSchema, lineItemSchema, TC defaults
  po-status-badge.test.tsx   17 tests — all 10 statuses
  po-line-items-editor.test.tsx 20 tests — add/remove, GST calc, read-only, diff mode

Integration (Vitest + real DB, mocked auth/notifier):
  create-po.test.ts          — S-01 create, S-02 draft, S-03 submit
  approval-actions.test.ts   — M-02 approve, M-03 reject, M-04 edits/vendor-id, S-06/S-07
  payment-actions.test.ts    — A-01 queue, A-02 mark paid with reference

E2E (Playwright):
  auth.spec.ts               — login, role nav, sign out
  submitter-journey.spec.ts  — S-01 to S-08
  manager-approvals.spec.ts  — M-01 to M-04
  accounts-payment.spec.ts   — A-01, A-02
  po-export.spec.ts          — export buttons, endpoint responses, PDF content
This commit is contained in:
Hardik 2026-05-06 00:16:10 +05:30
parent 5cb8b228b1
commit e07ce9bd02
16 changed files with 1918 additions and 0 deletions

View file

@ -0,0 +1,115 @@
/**
* E2E Accounts payment workflow.
* Covers: A-01 (view payment queue), A-02 (mark PO as paid with reference).
*/
import { test, expect, type Page } from "@playwright/test";
const TECH = { email: "tech@pelagia.local", password: "tech1234" };
const MGR = { email: "manager@pelagia.local", password: "manager1234" };
const ACCT = { email: "accounts@pelagia.local", password: "accounts1234" };
async function login(page: Page, creds: typeof TECH) {
await page.goto("/login");
await page.getByLabel(/email/i).fill(creds.email);
await page.getByLabel(/password/i).fill(creds.password);
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/login/);
}
/** Full flow: create PO as tech → approve as manager → return PO URL */
async function createApprovedPo(page: Page, context: import("@playwright/test").BrowserContext, title: string) {
// Step 1: create + submit as tech
await login(page, TECH);
await page.goto("/po/new");
await page.getByLabel(/title/i).fill(title);
await page.getByLabel(/vessel/i).selectOption({ index: 1 });
await page.getByLabel(/account/i).selectOption({ index: 1 });
await page.getByPlaceholder("Item description").fill("Deck paint");
await page.getByRole("spinbutton").first().fill("3");
await page.locator("input[placeholder='0.00']").fill("800");
await page.getByRole("button", { name: /submit for approval/i }).click();
await expect(page).toHaveURL(/\/po\//);
const poUrl = page.url();
// Step 2: approve as manager
await context.clearCookies();
await login(page, MGR);
await page.goto(poUrl);
await page.getByRole("button", { name: /^approve$/i }).click();
await expect(page.getByText(/approved/i)).toBeVisible();
return poUrl;
}
// ── A-01: Payment queue ───────────────────────────────────────────────────────
test("A-01 — accounts user sees the payment queue page", async ({ page }) => {
await login(page, ACCT);
await page.goto("/payments");
await expect(page.getByRole("heading", { name: /payment/i })).toBeVisible();
// Should show a table or list of approved POs
await expect(page.locator("table, [role='list']")).toBeVisible();
});
test("A-01 — accounts dashboard shows payment queue CTA", async ({ page }) => {
await login(page, ACCT);
await expect(page.getByRole("link", { name: /payment/i })).toBeVisible();
});
test("A-01 — TECHNICAL role cannot access payment queue", async ({ page }) => {
await login(page, TECH);
await page.goto("/payments");
await expect(page).not.toHaveURL(/payments/);
});
// ── A-02: Mark as paid ────────────────────────────────────────────────────────
test("A-02 — accounts user can start processing payment (MGR_APPROVED → SENT_FOR_PAYMENT)", async ({ page, context }) => {
const title = `E2E_ACCT_PROCESS_${Date.now()}`;
const poUrl = await createApprovedPo(page, context, title);
await context.clearCookies();
await login(page, ACCT);
await page.goto(poUrl);
const processBtn = page.getByRole("button", { name: /process payment|start payment/i });
await expect(processBtn).toBeVisible();
await processBtn.click();
await expect(page.getByText(/sent for payment/i)).toBeVisible();
});
test("A-02 — accounts user can mark PO as paid with a reference number", async ({ page, context }) => {
const title = `E2E_ACCT_PAID_${Date.now()}`;
const poUrl = await createApprovedPo(page, context, title);
await context.clearCookies();
await login(page, ACCT);
await page.goto(poUrl);
// Process payment first
await page.getByRole("button", { name: /process payment|start payment/i }).click();
await expect(page.getByText(/sent for payment/i)).toBeVisible();
// Mark as paid
const paymentRefInput = page.getByPlaceholder(/reference|ref/i).first();
await paymentRefInput.fill("NEFT/2026/TEST001");
await page.getByRole("button", { name: /mark.*paid|confirm payment/i }).click();
await expect(page.getByText(/paid/i)).toBeVisible();
});
test("A-02 — payment reference is required to mark as paid", async ({ page, context }) => {
const title = `E2E_ACCT_NOREF_${Date.now()}`;
const poUrl = await createApprovedPo(page, context, title);
await context.clearCookies();
await login(page, ACCT);
await page.goto(poUrl);
await page.getByRole("button", { name: /process payment|start payment/i }).click();
await expect(page.getByText(/sent for payment/i)).toBeVisible();
// Try to mark as paid without a reference
await page.getByRole("button", { name: /mark.*paid|confirm payment/i }).click();
// Should show validation error or stay on same page
await expect(page.getByText(/sent for payment/i)).toBeVisible();
});

View file

@ -0,0 +1,50 @@
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("redirects unauthenticated user to login", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
test("shows login form elements", async ({ page }) => {
await page.goto("/login");
await expect(page.getByLabel("Email address")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
});
test("shows error on invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email address").fill("wrong@example.com");
await page.getByLabel("Password").fill("wrongpassword");
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page.getByText(/invalid email or password/i)).toBeVisible();
});
test("logs in successfully and redirects to dashboard", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email address").fill("tech@pelagia.local");
await page.getByLabel("Password").fill("tech1234");
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText("Dashboard")).toBeVisible();
});
test("manager sees approvals in navigation", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email address").fill("manager@pelagia.local");
await page.getByLabel("Password").fill("manager1234");
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("link", { name: /approvals/i })).toBeVisible();
});
test("signs out and returns to login", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email address").fill("tech@pelagia.local");
await page.getByLabel("Password").fill("tech1234");
await page.getByRole("button", { name: /sign in/i }).click();
await page.getByTitle("Sign out").click();
await expect(page).toHaveURL(/\/login/);
});
});

View file

@ -0,0 +1,152 @@
/**
* E2E Manager approval workflow.
* Covers: M-01 (approvals queue), M-02 (approve / approve+note),
* M-03 (reject with reason), M-04 (request edits, flag vendor ID).
*/
import { test, expect, type Page } from "@playwright/test";
const TECH = { email: "tech@pelagia.local", password: "tech1234" };
const MGR = { email: "manager@pelagia.local", password: "manager1234" };
async function login(page: Page, creds: typeof TECH) {
await page.goto("/login");
await page.getByLabel(/email/i).fill(creds.email);
await page.getByLabel(/password/i).fill(creds.password);
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/login/);
}
/** Create + submit a PO as tech user, return the PO URL */
async function submitPoAsTech(page: Page, title: string): Promise<string> {
await login(page, TECH);
await page.goto("/po/new");
await page.getByLabel(/title/i).fill(title);
await page.getByLabel(/vessel/i).selectOption({ index: 1 });
await page.getByLabel(/account/i).selectOption({ index: 1 });
await page.getByPlaceholder("Item description").fill("Engine filter");
await page.getByRole("spinbutton").first().fill("2");
await page.locator("input[placeholder='0.00']").fill("500");
await page.getByRole("button", { name: /submit for approval/i }).click();
await expect(page).toHaveURL(/\/po\//);
return page.url();
}
// ── M-01: Approvals queue ────────────────────────────────────────────────────
test("M-01 — manager sees approvals queue with pending POs", async ({ page }) => {
await login(page, MGR);
await page.goto("/approvals");
await expect(page.getByRole("heading", { name: /approval/i })).toBeVisible();
// Should have table or list of POs
await expect(page.locator("table, [role='list']")).toBeVisible();
});
test("M-01 — manager can search approvals queue by PO number", async ({ page }) => {
await login(page, MGR);
await page.goto("/approvals");
const searchInput = page.getByPlaceholder(/search|po number/i);
if (await searchInput.isVisible()) {
await searchInput.fill("PO-");
await expect(searchInput).toHaveValue("PO-");
}
});
// ── M-02: Approve ─────────────────────────────────────────────────────────────
test("M-02 — manager can approve a submitted PO", async ({ page, context }) => {
const title = `E2E_MGR_APPROVE_${Date.now()}`;
await submitPoAsTech(page, title);
const poUrl = page.url();
// Switch to manager session
await context.clearCookies();
await login(page, MGR);
await page.goto(poUrl);
await expect(page.getByText(/under review/i)).toBeVisible();
await page.getByRole("button", { name: /^approve$/i }).click();
await expect(page.getByText(/approved/i)).toBeVisible();
});
test("M-02 — manager can approve with a note", async ({ page, context }) => {
const title = `E2E_MGR_APPRNOTE_${Date.now()}`;
await submitPoAsTech(page, title);
const poUrl = page.url();
await context.clearCookies();
await login(page, MGR);
await page.goto(poUrl);
await page.getByRole("button", { name: /approve.*note/i }).click();
const noteInput = page.getByPlaceholder(/note|comment/i).first();
await noteInput.fill("Approved — please expedite delivery");
await page.getByRole("button", { name: /^approve$/i }).last().click();
await expect(page.getByText(/approved/i)).toBeVisible();
});
// ── M-03: Reject ─────────────────────────────────────────────────────────────
test("M-03 — manager can reject a PO with a reason", async ({ page, context }) => {
const title = `E2E_MGR_REJECT_${Date.now()}`;
await submitPoAsTech(page, title);
const poUrl = page.url();
await context.clearCookies();
await login(page, MGR);
await page.goto(poUrl);
await page.getByRole("button", { name: /reject/i }).click();
const noteInput = page.getByPlaceholder(/reason|note/i).first();
await noteInput.fill("Budget not available for this quarter");
await page.getByRole("button", { name: /^reject$/i }).last().click();
await expect(page.getByText(/rejected/i)).toBeVisible();
});
// ── M-04: Request edits ──────────────────────────────────────────────────────
test("M-04 — manager can request edits with a note", async ({ page, context }) => {
const title = `E2E_MGR_EDITS_${Date.now()}`;
await submitPoAsTech(page, title);
const poUrl = page.url();
await context.clearCookies();
await login(page, MGR);
await page.goto(poUrl);
await page.getByRole("button", { name: /request edits/i }).click();
const noteInput = page.getByPlaceholder(/reason|note/i).first();
await noteInput.fill("Please add vendor ID and update quantity");
await page.getByRole("button", { name: /request edits/i }).last().click();
await expect(page.getByText(/edits requested/i)).toBeVisible();
});
// ── M-04: Flag vendor ID ──────────────────────────────────────────────────────
test("M-04 — manager can flag a PO for vendor ID verification", async ({ page, context }) => {
const title = `E2E_MGR_VENDOR_${Date.now()}`;
await submitPoAsTech(page, title);
const poUrl = page.url();
await context.clearCookies();
await login(page, MGR);
await page.goto(poUrl);
const vendorIdBtn = page.getByRole("button", { name: /request vendor id|vendor id/i });
await expect(vendorIdBtn).toBeVisible();
await vendorIdBtn.click();
await expect(page.getByText(/vendor id pending/i)).toBeVisible();
});
// ── Manager dashboard ─────────────────────────────────────────────────────────
test("manager dashboard shows approvals queue link", async ({ page }) => {
await login(page, MGR);
await expect(page.getByRole("link", { name: /approval/i })).toBeVisible();
});
test("manager cannot create a new PO (no create_po permission)", async ({ page }) => {
await login(page, MGR);
await page.goto("/po/new");
// Should redirect away — managers can't create POs
await expect(page).not.toHaveURL(/\/po\/new/);
});

View file

@ -0,0 +1,122 @@
/**
* E2E Individual PO export (PDF + XLSX).
* Verifies export buttons are present and the export endpoints respond correctly.
*/
import { test, expect, type Page } from "@playwright/test";
const TECH = { email: "tech@pelagia.local", password: "tech1234" };
const MGR = { email: "manager@pelagia.local", password: "manager1234" };
async function login(page: Page, creds: typeof TECH) {
await page.goto("/login");
await page.getByLabel(/email/i).fill(creds.email);
await page.getByLabel(/password/i).fill(creds.password);
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/login/);
}
async function createDraftPo(page: Page, title: string): Promise<string> {
await page.goto("/po/new");
await page.getByLabel(/title/i).fill(title);
await page.getByLabel(/vessel/i).selectOption({ index: 1 });
await page.getByLabel(/account/i).selectOption({ index: 1 });
await page.getByPlaceholder("Item description").fill("Test item for export");
await page.getByRole("spinbutton").first().fill("1");
await page.locator("input[placeholder='0.00']").fill("100");
await page.getByRole("button", { name: /save as draft/i }).click();
await expect(page).toHaveURL(/\/po\//);
return page.url();
}
// ── Export buttons visible ────────────────────────────────────────────────────
test("Export PDF button is visible on PO detail page", async ({ page }) => {
await login(page, TECH);
await createDraftPo(page, `E2E_EXPORT_BTN_${Date.now()}`);
await expect(page.getByRole("link", { name: /export pdf/i })).toBeVisible();
});
test("Export XLSX button is visible on PO detail page", async ({ page }) => {
await login(page, TECH);
await createDraftPo(page, `E2E_EXPORT_XLSX_BTN_${Date.now()}`);
await expect(page.getByRole("link", { name: /export xlsx/i })).toBeVisible();
});
// ── Export endpoints respond ──────────────────────────────────────────────────
test("PDF export endpoint returns HTML content", async ({ page }) => {
await login(page, TECH);
const poUrl = await createDraftPo(page, `E2E_PDF_EP_${Date.now()}`);
// Extract PO id from URL: /po/{id}
const poId = poUrl.split("/po/")[1].replace(/\/$/, "");
const response = await page.request.get(`/api/po/${poId}/export?format=pdf`);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("text/html");
const body = await response.text();
expect(body).toContain("PURCHASE ORDER");
expect(body).toContain("PELAGIA MARINE SERVICES");
});
test("XLSX export endpoint returns a binary spreadsheet", async ({ page }) => {
await login(page, TECH);
const poUrl = await createDraftPo(page, `E2E_XLSX_EP_${Date.now()}`);
const poId = poUrl.split("/po/")[1].replace(/\/$/, "");
const response = await page.request.get(`/api/po/${poId}/export?format=xlsx`);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("spreadsheetml");
expect(response.headers()["content-disposition"]).toMatch(/\.xlsx/);
});
test("export endpoint returns 401 for unauthenticated requests", async ({ page }) => {
// Don't log in — use a fresh context
const response = await page.request.get("/api/po/nonexistent-id/export?format=pdf");
expect([401, 404]).toContain(response.status());
});
// ── PDF content matches PO data ───────────────────────────────────────────────
test("PDF export contains PO number, company header and T&C section", async ({ page }) => {
await login(page, TECH);
const poUrl = await createDraftPo(page, `E2E_PDF_CONTENT_${Date.now()}`);
const poId = poUrl.split("/po/")[1].replace(/\/$/, "");
const response = await page.request.get(`/api/po/${poId}/export?format=pdf`);
const body = await response.text();
expect(body).toContain("PELAGIA MARINE SERVICES");
expect(body).toContain("PURCHASE ORDER");
expect(body).toContain("INSTRUCTIONS TO VENDORS");
// GST table headers
expect(body).toContain("Taxable Cost");
expect(body).toContain("GST%");
expect(body).toContain("GRAND TOTAL");
// Signature block
expect(body).toContain("Authorized Signatory");
});
test("PDF export shows the fixed T&C line 1", async ({ page }) => {
await login(page, TECH);
const poUrl = await createDraftPo(page, `E2E_PDF_TC_${Date.now()}`);
const poId = poUrl.split("/po/")[1].replace(/\/$/, "");
const response = await page.request.get(`/api/po/${poId}/export?format=pdf`);
const body = await response.text();
expect(body).toMatch(/please quote this purchase order/i);
});
// ── Manager can also export ───────────────────────────────────────────────────
test("manager can see and use the Export PDF button on any PO detail", async ({ page }) => {
await login(page, MGR);
await page.goto("/approvals");
// Click first PO in the queue if available
const firstPoLink = page.locator("a[href^='/po/']").first();
if (await firstPoLink.count() > 0) {
await firstPoLink.click();
await expect(page.getByRole("link", { name: /export pdf/i })).toBeVisible();
}
});

View file

@ -0,0 +1,134 @@
/**
* E2E Submitter user journey.
* Covers: S-01 (create PO), S-02 (save draft), S-03 (submit for approval),
* S-05 (view status), S-07 (edit and resubmit), S-08 (confirm receipt page visible).
*/
import { test, expect, type Page } from "@playwright/test";
const TECH = { email: "tech@pelagia.local", password: "tech1234" };
const MGR = { email: "manager@pelagia.local", password: "manager1234" };
async function login(page: Page, creds: typeof TECH) {
await page.goto("/login");
await page.getByLabel(/email/i).fill(creds.email);
await page.getByLabel(/password/i).fill(creds.password);
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/login/);
}
async function fillNewPoForm(page: Page, title: string) {
await page.goto("/po/new");
await page.getByLabel(/title/i).fill(title);
await page.getByLabel(/vessel/i).selectOption({ index: 1 });
await page.getByLabel(/account/i).selectOption({ index: 1 });
// Line item
await page.getByPlaceholder("Item description").fill("Test marine part");
await page.getByRole("spinbutton").first().fill("5");
await page.locator("input[placeholder='0.00']").fill("100");
}
// ── S-02: Save as draft ──────────────────────────────────────────────────────
test("S-02 — submitter can save a PO as draft", async ({ page }) => {
await login(page, TECH);
const title = `E2E_DRAFT_${Date.now()}`;
await fillNewPoForm(page, title);
await page.getByRole("button", { name: /save as draft/i }).click();
await expect(page).toHaveURL(/\/po\//);
await expect(page.getByText("Draft")).toBeVisible();
});
// ── S-01 + S-03: Create and submit ──────────────────────────────────────────
test("S-01 + S-03 — submitter can create a PO and submit for approval", async ({ page }) => {
await login(page, TECH);
const title = `E2E_SUBMIT_${Date.now()}`;
await fillNewPoForm(page, title);
await page.getByRole("button", { name: /submit for approval/i }).click();
await expect(page).toHaveURL(/\/po\//);
await expect(page.getByText(/under review/i)).toBeVisible();
});
// ── S-01: Line items with GST ────────────────────────────────────────────────
test("S-01 — new PO form shows GST rate dropdown on line items", async ({ page }) => {
await login(page, TECH);
await page.goto("/po/new");
// GST select should be visible with 18% default
const gstSelect = page.locator("select").filter({ hasText: "18%" });
await expect(gstSelect).toBeVisible();
});
// ── S-01: T&C structured fields ──────────────────────────────────────────────
test("S-01 — new PO form shows structured T&C section with fixed first line", async ({ page }) => {
await login(page, TECH);
await page.goto("/po/new");
await expect(page.getByText(/please quote this purchase order/i)).toBeVisible();
await expect(page.getByLabel(/delivery/i).first()).toBeVisible();
await expect(page.getByLabel(/payment terms/i)).toBeVisible();
});
// ── S-05: View status and activity ──────────────────────────────────────────
test("S-05 — submitter can view the status and activity trail of their PO", async ({ page }) => {
await login(page, TECH);
// Go to My Orders
await page.goto("/my-orders");
await expect(page.getByRole("heading", { name: /my orders|orders/i })).toBeVisible();
// Should have at least the seeded POs or any created ones
// Click the first PO if available
const firstLink = page.locator("a[href^='/po/']").first();
if (await firstLink.count() > 0) {
await firstLink.click();
await expect(page.getByText(/activity/i)).toBeVisible();
}
});
// ── S-07: Edit PO when edits requested ──────────────────────────────────────
test("S-07 — submitter sees edit form pre-populated with existing values", async ({ page }) => {
await login(page, TECH);
const title = `E2E_EDIT_${Date.now()}`;
await fillNewPoForm(page, title);
await page.getByRole("button", { name: /save as draft/i }).click();
await expect(page).toHaveURL(/\/po\//);
// Click Edit
await page.getByRole("link", { name: /edit/i }).click();
await expect(page).toHaveURL(/\/edit$/);
// Form should be pre-populated
await expect(page.getByLabel(/title/i)).toHaveValue(title);
await expect(page.getByPlaceholder("Item description")).toHaveValue("Test marine part");
});
// ── S-08: Confirm receipt page ───────────────────────────────────────────────
test("S-08 — receipt confirmation page is accessible at /po/[id]/receipt", async ({ page }) => {
await login(page, TECH);
// Use the seeded PAID_DELIVERED PO if present, otherwise just check the route exists
// The seed creates a PO in PAID_DELIVERED state
await page.goto("/my-orders");
const paidLink = page.getByText(/paid|confirm receipt/i).first();
if (await paidLink.isVisible()) {
await paidLink.click();
// Should see confirm receipt CTA
await expect(page.getByRole("link", { name: /confirm receipt/i })).toBeVisible();
}
});
// ── Navigation ───────────────────────────────────────────────────────────────
test("submitter dashboard shows New PO button", async ({ page }) => {
await login(page, TECH);
await expect(page.getByRole("link", { name: /new (po|purchase order)/i })).toBeVisible();
});
test("submitter cannot access approvals queue", async ({ page }) => {
await login(page, TECH);
await page.goto("/approvals");
// Should redirect or show dashboard (not the approvals queue)
await expect(page).not.toHaveURL(/approvals/);
});

View file

@ -0,0 +1,235 @@
/**
* Integration tests for manager approval server actions.
* Covers: M-02 (approve / approve+note), M-03 (reject), M-04 (request edits, vendor ID), S-06 (provide vendor ID), S-07 (resubmit after edits).
*/
import { vi, describe, it, expect, beforeAll, beforeEach, 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 { updatePo } from "@/app/(portal)/po/[id]/edit/actions";
import {
approvePo, rejectPo, requestEdits, 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_APPROVAL_";
let techId: string;
let managerId: string;
let vesselId: string;
let accountId: string;
let vendorId: string;
beforeAll(async () => {
const [tech, mgr, vessel, account, vendor] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedVessel("MV Ocean Pride"),
getSeedAccount("700201"),
getSeedVendor("Apar Industries Ltd"),
]);
techId = tech.id;
managerId = mgr.id;
vesselId = vessel.id;
accountId = account.id;
vendorId = vendor.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
// Helper: create a PO in MGR_REVIEW state
async function createSubmittedPo(title: string): Promise<string> {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
const result = await createPo(form);
return (result as { id: string }).id;
}
// ── M-02: Approve ─────────────────────────────────────────────────────────────
describe("M-02 — approve PO", () => {
it("transitions PO from MGR_REVIEW to MGR_APPROVED", async () => {
const poId = await createSubmittedPo(`${PREFIX}Approve`);
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");
expect(po?.approvedAt).not.toBeNull();
});
it("stores managerNote when approving with note", async () => {
const poId = await createSubmittedPo(`${PREFIX}ApproveNote`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId, note: "Approved — expedite delivery", withNote: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.managerNote).toBe("Approved — expedite delivery");
const action = await db.pOAction.findFirst({
where: { poId, actionType: "APPROVED_WITH_NOTE" },
});
expect(action).not.toBeNull();
});
it("notifies submitter and accounts on approval", async () => {
const { notify } = await import("@/lib/notifier");
vi.mocked(notify).mockClear();
const poId = await createSubmittedPo(`${PREFIX}ApproveNotify`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId });
expect(vi.mocked(notify)).toHaveBeenCalledWith(
expect.objectContaining({ event: "PO_APPROVED" })
);
});
it("returns error when TECHNICAL role tries to approve", async () => {
const poId = await createSubmittedPo(`${PREFIX}ApproveForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await approvePo({ poId });
expect(result).toHaveProperty("error");
});
it("returns error when PO is not in MGR_REVIEW state", async () => {
// Create a DRAFT PO, don't submit
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}ApproveDraft`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvePo({ poId });
expect(result).toHaveProperty("error");
});
});
// ── M-03: Reject ──────────────────────────────────────────────────────────────
describe("M-03 — reject PO", () => {
it("transitions PO from MGR_REVIEW to REJECTED with note", async () => {
const poId = await createSubmittedPo(`${PREFIX}Reject`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("REJECTED");
expect(po?.managerNote).toBe("Budget exceeded for this quarter");
});
it("creates a REJECTED action entry in the audit trail", async () => {
const poId = await createSubmittedPo(`${PREFIX}RejectAudit`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await rejectPo({ poId, note: "Not needed" });
const action = await db.pOAction.findFirst({ where: { poId, actionType: "REJECTED" } });
expect(action?.note).toBe("Not needed");
});
it("notifies submitter on rejection", async () => {
const { notify } = await import("@/lib/notifier");
vi.mocked(notify).mockClear();
const poId = await createSubmittedPo(`${PREFIX}RejectNotify`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await rejectPo({ poId, note: "See notes" });
expect(vi.mocked(notify)).toHaveBeenCalledWith(
expect.objectContaining({ event: "PO_REJECTED" })
);
});
});
// ── M-04: Request edits ──────────────────────────────────────────────────────
describe("M-04 — request edits", () => {
it("transitions PO to EDITS_REQUESTED with manager note", async () => {
const poId = await createSubmittedPo(`${PREFIX}Edits`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await requestEdits({ poId, note: "Please add vendor ID" });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("EDITS_REQUESTED");
expect(po?.managerNote).toBe("Please add vendor ID");
});
});
// ── M-04: Request vendor ID ──────────────────────────────────────────────────
describe("M-04 — request vendor ID", () => {
it("transitions PO to VENDOR_ID_PENDING", async () => {
const poId = await createSubmittedPo(`${PREFIX}VendorIdReq`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await requestVendorId({ poId });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("VENDOR_ID_PENDING");
});
});
// ── S-06: Provide vendor ID ──────────────────────────────────────────────────
describe("S-06 — provide vendor ID", () => {
it("transitions VENDOR_ID_PENDING back to MGR_REVIEW", async () => {
const poId = await createSubmittedPo(`${PREFIX}ProvideVendor`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await requestVendorId({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await provideVendorId({ poId, vendorId });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("MGR_REVIEW");
expect(po?.vendorId).toBe(vendorId);
});
});
// ── S-07: Edit and resubmit ──────────────────────────────────────────────────
describe("S-07 — edit and resubmit after edits requested", () => {
it("resubmitting from EDITS_REQUESTED transitions to MGR_REVIEW", async () => {
const poId = await createSubmittedPo(`${PREFIX}Resubmit`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await requestEdits({ poId, note: "Update line items" });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "resubmit" });
const result = await updatePo(poId, form);
expect(result).toEqual({ id: poId });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("MGR_REVIEW");
});
it("saving edits without resubmitting stays as DRAFT (save intent)", async () => {
// Create a DRAFT PO
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string };
const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "save" });
const result = await updatePo(poId, editForm);
expect(result).toEqual({ id: poId });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("DRAFT");
});
});

View file

@ -0,0 +1,192 @@
/**
* Integration tests for PO creation server action.
* Covers: S-01 (create with line items), S-02 (save as draft), S-03 (submit for approval).
*/
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 {
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
makePoForm, deletePosByTitle,
} from "./helpers";
const PREFIX = "INTTEST_CREATE_";
let techId: string;
let vesselId: string;
let accountId: string;
let vendorId: string;
beforeAll(async () => {
const [tech, vessel, account, vendor] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedVessel("MV Ocean Pride"),
getSeedAccount("700201"),
getSeedVendor("Apar Industries Ltd"),
]);
techId = tech.id;
vesselId = vessel.id;
accountId = account.id;
vendorId = vendor.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
// ── S-02: Save as draft ──────────────────────────────────────────────────────
describe("S-02 — save as draft", () => {
it("creates a PO in DRAFT status", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({
title: `${PREFIX}Draft`,
vesselId, accountId, intent: "draft",
});
const result = await createPo(form);
expect(result).not.toHaveProperty("error");
const id = (result as { id: string }).id;
const po = await db.purchaseOrder.findUnique({ where: { id } });
expect(po?.status).toBe("DRAFT");
expect(po?.submittedAt).toBeNull();
});
it("returns error for unauthenticated request", 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("returns error when ACCOUNTS role tries to create a PO", async () => {
const acct = await getSeedUser("accounts@pelagia.local");
vi.mocked(auth).mockResolvedValue(makeSession(acct.id, "ACCOUNTS"));
const form = makePoForm({ title: `${PREFIX}ForbiddenAccts`, vesselId, accountId });
const result = await createPo(form);
expect(result).toHaveProperty("error");
});
it("returns error when a required field (vesselId) is missing", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = new FormData();
form.set("title", `${PREFIX}NoVessel`);
form.set("accountId", accountId);
form.set("intent", "draft");
form.set("lineItems[0].description", "Item");
form.set("lineItems[0].quantity", "1");
form.set("lineItems[0].unit", "pc");
form.set("lineItems[0].unitPrice", "50");
form.set("lineItems[0].gstRate", "0.18");
const result = await createPo(form);
expect(result).toHaveProperty("error");
});
});
// ── S-01: Create with line items ─────────────────────────────────────────────
describe("S-01 — create PO with line items", () => {
it("stores line items with correct quantity, unit price, and GST rate", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({
title: `${PREFIX}LineItems`,
vesselId, accountId, vendorId, intent: "draft",
lineItems: [
{ description: "Gear Oil 80W90", quantity: 50, unit: "L", unitPrice: 182, gstRate: 0.18 },
{ description: "Engine Filter", quantity: 4, unit: "pc", unitPrice: 250, gstRate: 0.12 },
],
});
const result = await createPo(form);
const id = (result as { id: string }).id;
const po = await db.purchaseOrder.findUnique({
where: { id },
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
});
expect(po?.lineItems).toHaveLength(2);
expect(Number(po!.lineItems[0].quantity)).toBe(50);
expect(Number(po!.lineItems[0].unitPrice)).toBe(182);
expect(Number(po!.lineItems[0].gstRate)).toBeCloseTo(0.18);
expect(Number(po!.lineItems[1].unitPrice)).toBe(250);
expect(Number(po!.lineItems[1].gstRate)).toBeCloseTo(0.12);
});
it("sets totalAmount to grand total including GST", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
// 10 × 100 × 1.18 = 1180
const form = makePoForm({
title: `${PREFIX}GrandTotal`,
vesselId, accountId, intent: "draft",
lineItems: [{ description: "Item", quantity: 10, unit: "pc", unitPrice: 100, gstRate: 0.18 }],
});
const result = await createPo(form);
const id = (result as { id: string }).id;
const po = await db.purchaseOrder.findUnique({ where: { id } });
expect(Number(po!.totalAmount)).toBeCloseTo(1180, 1);
});
it("stores optional fields (PI quotation no, place of delivery, TC fields)", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Optional`, vesselId, accountId, intent: "draft" });
form.set("piQuotationNo", "Verbal");
form.set("placeOfDelivery", "CBD Belapur, Navi Mumbai");
form.set("tcDelivery", "Within 7 days");
form.set("tcPaymentTerms", "Net 45");
const result = await createPo(form);
const id = (result as { id: string }).id;
const po = await db.purchaseOrder.findUnique({ where: { id } });
expect((po as any).piQuotationNo).toBe("Verbal");
expect((po as any).placeOfDelivery).toBe("CBD Belapur, Navi Mumbai");
expect((po as any).tcDelivery).toBe("Within 7 days");
expect((po as any).tcPaymentTerms).toBe("Net 45");
});
it("allows MANNING role to create a PO", async () => {
const manning = await getSeedUser("manning@pelagia.local");
vi.mocked(auth).mockResolvedValue(makeSession(manning.id, "MANNING"));
const form = makePoForm({ title: `${PREFIX}Manning`, vesselId, accountId });
const result = await createPo(form);
expect(result).not.toHaveProperty("error");
});
});
// ── S-03: Submit for approval ─────────────────────────────────────────────────
describe("S-03 — submit for approval", () => {
it("creates PO with status MGR_REVIEW and sets submittedAt", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
const result = await createPo(form);
expect(result).not.toHaveProperty("error");
const id = (result as { id: string }).id;
const po = await db.purchaseOrder.findUnique({ where: { id } });
expect(po?.status).toBe("MGR_REVIEW");
expect(po?.submittedAt).not.toBeNull();
});
it("sends notification to managers on submit", async () => {
const { notify } = await import("@/lib/notifier");
vi.mocked(notify).mockClear();
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" });
await createPo(form);
expect(vi.mocked(notify)).toHaveBeenCalledWith(
expect.objectContaining({ event: "PO_SUBMITTED" })
);
});
});

View file

@ -0,0 +1,87 @@
import { db } from "@/lib/db";
import type { Role } from "@prisma/client";
// ── Session factory ──────────────────────────────────────────────────────────
export function makeSession(userId: string, role: Role, name = "Test User") {
return {
user: { id: userId, name, email: `${role.toLowerCase()}@test.local`, role },
expires: new Date(Date.now() + 86_400_000).toISOString(),
};
}
// ── Seeded user lookup (from prisma/seed.ts) ─────────────────────────────────
export async function getSeedUser(email: string) {
const user = await db.user.findUniqueOrThrow({ where: { email } });
return user;
}
export async function getSeedVessel(name: string) {
const v = await db.vessel.findFirstOrThrow({ where: { name } });
return v;
}
export async function getSeedAccount(code: string) {
const a = await db.account.findUniqueOrThrow({ where: { code } });
return a;
}
export async function getSeedVendor(name: string) {
const v = await db.vendor.findFirstOrThrow({ where: { name } });
return v;
}
// ── FormData builder ─────────────────────────────────────────────────────────
export function fd(fields: Record<string, string>): FormData {
const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.set(k, v);
return form;
}
// Lines items appended to an existing FormData
export function appendLineItem(
form: FormData,
idx: number,
item: { description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }
) {
form.set(`lineItems[${idx}].description`, item.description);
form.set(`lineItems[${idx}].quantity`, String(item.quantity));
form.set(`lineItems[${idx}].unit`, item.unit);
form.set(`lineItems[${idx}].unitPrice`, String(item.unitPrice));
form.set(`lineItems[${idx}].gstRate`, String(item.gstRate ?? 0.18));
}
export function makePoForm(overrides: {
title?: string;
vesselId: string;
accountId: string;
vendorId?: string;
intent?: "draft" | "submit";
lineItems?: Array<{ description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }>;
}): FormData {
const form = new FormData();
form.set("title", overrides.title ?? "Test PO");
form.set("vesselId", overrides.vesselId);
form.set("accountId", overrides.accountId);
form.set("intent", overrides.intent ?? "draft");
if (overrides.vendorId) form.set("vendorId", overrides.vendorId);
const items = overrides.lineItems ?? [
{ description: "Test Item", quantity: 10, unit: "pc", unitPrice: 100 },
];
items.forEach((item, i) => appendLineItem(form, i, item));
return form;
}
// ── Cleanup helpers ──────────────────────────────────────────────────────────
export async function deletePo(poId: string) {
await db.purchaseOrder.delete({ where: { id: poId } }).catch(() => {});
}
export async function deletePosByTitle(titlePrefix: string) {
await db.purchaseOrder.deleteMany({
where: { title: { startsWith: titlePrefix } },
});
}

View file

@ -0,0 +1,147 @@
/**
* Integration tests for accounts payment server actions.
* Covers: A-01 (payment queue PO reaches MGR_APPROVED), A-02 (mark paid with reference number).
*/
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 { processPayment, markPaid } from "@/app/(portal)/payments/actions";
import {
makeSession, getSeedUser, getSeedVessel, getSeedAccount,
makePoForm, deletePosByTitle,
} from "./helpers";
const PREFIX = "INTTEST_PAYMENT_";
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 Sea Breeze"),
getSeedAccount("700202"),
]);
techId = tech.id;
managerId = mgr.id;
accountsId = acct.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
// Helper: create PO → submit → approve (reaches MGR_APPROVED)
async function createApprovedPo(title: string): Promise<string> {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId });
return poId;
}
// ── A-01: PO reaches payment queue (MGR_APPROVED) ───────────────────────────
describe("A-01 — approved PO appears in payment queue", () => {
it("PO has status MGR_APPROVED after manager approves", async () => {
const poId = await createApprovedPo(`${PREFIX}Queue`);
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("MGR_APPROVED");
});
it("processPayment transitions MGR_APPROVED to SENT_FOR_PAYMENT", async () => {
const poId = await createApprovedPo(`${PREFIX}ProcessPayment`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await processPayment({ poId });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("SENT_FOR_PAYMENT");
});
it("TECHNICAL role cannot process payment", async () => {
const poId = await createApprovedPo(`${PREFIX}PaymentForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await processPayment({ poId });
expect(result).toHaveProperty("error");
});
});
// ── A-02: Mark as paid with reference number ─────────────────────────────────
describe("A-02 — mark PO as paid with reference number", () => {
it("transitions SENT_FOR_PAYMENT to PAID_DELIVERED and stores paymentRef", async () => {
const poId = await createApprovedPo(`${PREFIX}MarkPaid`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234" });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("PAID_DELIVERED");
expect(po?.paymentRef).toBe("NEFT/2026/001234");
expect(po?.paidAt).not.toBeNull();
});
it("creates a PAYMENT_SENT action in the audit trail", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidAudit`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
await markPaid({ poId, paymentRef: "TXN-9999" });
const action = await db.pOAction.findFirst({ where: { poId, actionType: "PAYMENT_SENT" } });
expect(action).not.toBeNull();
expect((action?.metadata as { paymentRef?: string })?.paymentRef).toBe("TXN-9999");
});
it("returns error when paymentRef is missing", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidNoRef`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "" });
expect(result).toHaveProperty("error");
});
it("notifies submitter and managers when payment is marked", async () => {
const { notify } = await import("@/lib/notifier");
const poId = await createApprovedPo(`${PREFIX}PaidNotify`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
vi.mocked(notify).mockClear();
await processPayment({ poId });
await markPaid({ poId, paymentRef: "REF-42" });
const calls = vi.mocked(notify).mock.calls.map((c) => c[0].event);
expect(calls).toContain("PAYMENT_SENT");
});
it("MANAGER role cannot mark as paid (wrong permission)", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await markPaid({ poId, paymentRef: "MGR-REF" });
expect(result).toHaveProperty("error");
});
});

View file

@ -0,0 +1 @@
import "@testing-library/jest-dom";

View file

@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { hasPermission, requirePermission } from "@/lib/permissions";
describe("Permissions", () => {
describe("hasPermission", () => {
it("TECHNICAL can create POs", () => {
expect(hasPermission("TECHNICAL", "create_po")).toBe(true);
});
it("TECHNICAL cannot approve POs", () => {
expect(hasPermission("TECHNICAL", "approve_po")).toBe(false);
});
it("MANAGER can approve POs", () => {
expect(hasPermission("MANAGER", "approve_po")).toBe(true);
});
it("MANAGER cannot process payment", () => {
expect(hasPermission("MANAGER", "process_payment")).toBe(false);
});
it("ACCOUNTS can process payment", () => {
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);
expect(hasPermission("SUPERUSER", "process_payment")).toBe(true);
expect(hasPermission("SUPERUSER", "confirm_receipt")).toBe(true);
});
it("ADMIN can manage users", () => {
expect(hasPermission("ADMIN", "manage_users")).toBe(true);
});
it("AUDITOR has read-only access", () => {
expect(hasPermission("AUDITOR", "view_all_pos")).toBe(true);
expect(hasPermission("AUDITOR", "approve_po")).toBe(false);
expect(hasPermission("AUDITOR", "create_po")).toBe(false);
});
});
describe("requirePermission", () => {
it("does not throw when permission is granted", () => {
expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow();
});
it("throws when permission is denied", () => {
expect(() => requirePermission("TECHNICAL", "approve_po")).toThrow();
});
});
});

View file

@ -0,0 +1,178 @@
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 = {
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("Item description")).toHaveLength(1);
});
it("shows the initial description value", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const input = screen.getByPlaceholderText("Item description") 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("Item description");
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("Item description")).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("Item description")).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[] = [
{ description: "Item A", quantity: 5, unit: "pc", unitPrice: 100, gstRate: 0.18 },
{ 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
});
});
// ── 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/);
});
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import {
canPerformAction,
getAvailableActions,
getTransition,
requiresNote,
} from "@/lib/po-state-machine";
describe("PO State Machine", () => {
describe("canPerformAction", () => {
it("allows TECHNICAL to submit a DRAFT", () => {
expect(canPerformAction("DRAFT", "submit", "TECHNICAL")).toBe(true);
});
it("allows MANNING to submit a DRAFT", () => {
expect(canPerformAction("DRAFT", "submit", "MANNING")).toBe(true);
});
it("disallows ACCOUNTS from submitting a DRAFT", () => {
expect(canPerformAction("DRAFT", "submit", "ACCOUNTS")).toBe(false);
});
it("allows MANAGER to approve in MGR_REVIEW", () => {
expect(canPerformAction("MGR_REVIEW", "approve", "MANAGER")).toBe(true);
});
it("allows SUPERUSER to approve in MGR_REVIEW", () => {
expect(canPerformAction("MGR_REVIEW", "approve", "SUPERUSER")).toBe(true);
});
it("disallows TECHNICAL from approving", () => {
expect(canPerformAction("MGR_REVIEW", "approve", "TECHNICAL")).toBe(false);
});
it("allows ACCOUNTS to process payment on MGR_APPROVED", () => {
expect(canPerformAction("MGR_APPROVED", "process_payment", "ACCOUNTS")).toBe(true);
});
it("allows TECHNICAL to confirm receipt on PAID_DELIVERED", () => {
expect(canPerformAction("PAID_DELIVERED", "confirm_receipt", "TECHNICAL")).toBe(true);
});
it("disallows action on wrong status", () => {
expect(canPerformAction("CLOSED", "approve", "MANAGER")).toBe(false);
});
});
describe("getTransition", () => {
it("returns correct target state for submit from DRAFT", () => {
const t = getTransition("DRAFT", "submit");
expect(t?.to).toBe("SUBMITTED");
});
it("returns correct target state for approve from MGR_REVIEW", () => {
const t = getTransition("MGR_REVIEW", "approve");
expect(t?.to).toBe("MGR_APPROVED");
});
it("returns correct target state for reject from MGR_REVIEW", () => {
const t = getTransition("MGR_REVIEW", "reject");
expect(t?.to).toBe("REJECTED");
});
it("returns null for unknown action on status", () => {
expect(getTransition("CLOSED", "approve")).toBeNull();
});
});
describe("requiresNote", () => {
it("reject requires a note", () => {
expect(requiresNote("MGR_REVIEW", "reject")).toBe(true);
});
it("approve does not require a note", () => {
expect(requiresNote("MGR_REVIEW", "approve")).toBe(false);
});
it("approve_with_note requires a note", () => {
expect(requiresNote("MGR_REVIEW", "approve_with_note")).toBe(true);
});
it("request_edits requires a note", () => {
expect(requiresNote("MGR_REVIEW", "request_edits")).toBe(true);
});
});
describe("getAvailableActions", () => {
it("returns submit for TECHNICAL on DRAFT", () => {
const actions = getAvailableActions("DRAFT", "TECHNICAL");
expect(actions).toContain("submit");
});
it("returns all 5 actions for MANAGER on MGR_REVIEW", () => {
const actions = getAvailableActions("MGR_REVIEW", "MANAGER");
expect(actions).toContain("approve");
expect(actions).toContain("approve_with_note");
expect(actions).toContain("reject");
expect(actions).toContain("request_edits");
expect(actions).toContain("request_vendor_id");
});
it("returns empty for ACCOUNTS on DRAFT", () => {
const actions = getAvailableActions("DRAFT", "ACCOUNTS");
expect(actions).toHaveLength(0);
});
it("returns empty for closed PO", () => {
const actions = getAvailableActions("CLOSED", "MANAGER");
expect(actions).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { PO_STATUS_LABELS } from "@/lib/utils";
import type { POStatus } from "@prisma/client";
const ALL_STATUSES: POStatus[] = [
"DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING",
"EDITS_REQUESTED", "REJECTED", "MGR_APPROVED",
"SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED",
];
describe("PoStatusBadge", () => {
it.each(ALL_STATUSES)("renders the correct label for %s", (status) => {
render(<PoStatusBadge status={status} />);
expect(screen.getByText(PO_STATUS_LABELS[status])).toBeInTheDocument();
});
it("renders 'Draft' for DRAFT status", () => {
render(<PoStatusBadge status="DRAFT" />);
expect(screen.getByText("Draft")).toBeInTheDocument();
});
it("renders 'Approved' for MGR_APPROVED status", () => {
render(<PoStatusBadge status="MGR_APPROVED" />);
expect(screen.getByText("Approved")).toBeInTheDocument();
});
it("renders 'Rejected' for REJECTED status", () => {
render(<PoStatusBadge status="REJECTED" />);
expect(screen.getByText("Rejected")).toBeInTheDocument();
});
it("renders 'Under Review' for MGR_REVIEW status", () => {
render(<PoStatusBadge status="MGR_REVIEW" />);
expect(screen.getByText("Under Review")).toBeInTheDocument();
});
it("renders 'Edits Requested' for EDITS_REQUESTED status", () => {
render(<PoStatusBadge status="EDITS_REQUESTED" />);
expect(screen.getByText("Edits Requested")).toBeInTheDocument();
});
it("renders 'Vendor ID Pending' for VENDOR_ID_PENDING status", () => {
render(<PoStatusBadge status="VENDOR_ID_PENDING" />);
expect(screen.getByText("Vendor ID Pending")).toBeInTheDocument();
});
it("renders exactly one badge element", () => {
const { container } = render(<PoStatusBadge status="DRAFT" />);
// Badge renders as a single span/div-like element
expect(container.firstChild).not.toBeNull();
expect(container.children).toHaveLength(1);
});
});

View file

@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import {
formatCurrency, formatDate, formatDateTime,
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
} from "@/lib/utils";
describe("formatCurrency", () => {
it("formats numbers as INR by default", () => {
const result = formatCurrency(1000);
expect(result).toMatch(/1,000/);
expect(result).toMatch(/₹|INR/);
});
it("formats decimal amounts to 2 decimal places", () => {
const result = formatCurrency(1234.5);
expect(result).toMatch(/1,234/);
});
it("accepts string input and formats it", () => {
const result = formatCurrency("500");
expect(result).toMatch(/500/);
});
it("formats zero without error", () => {
const result = formatCurrency(0);
expect(result).toMatch(/0/);
});
it("formats large numbers with commas", () => {
const result = formatCurrency(225498);
expect(result).toMatch(/2,25,498|225,498/); // en-IN uses 2,25,498 grouping
});
});
describe("formatDate", () => {
it("returns a readable date string", () => {
const result = formatDate(new Date("2026-04-29"));
expect(result).toMatch(/2026/);
expect(result).toMatch(/Apr|29/);
});
it("accepts a date string as input", () => {
const result = formatDate("2026-01-15");
expect(result).toMatch(/2026/);
});
});
describe("formatDateTime", () => {
it("includes both date and time", () => {
const result = formatDateTime(new Date("2026-04-29T14:30:00"));
expect(result).toMatch(/2026/);
// Should contain hours
expect(result).toMatch(/\d{1,2}:\d{2}/);
});
});
describe("generatePoNumber", () => {
it("starts with PO-", () => {
expect(generatePoNumber()).toMatch(/^PO-/);
});
it("includes the current year", () => {
const year = new Date().getFullYear().toString();
expect(generatePoNumber()).toContain(year);
});
it("generates a 5-digit zero-padded sequence", () => {
const result = generatePoNumber();
// Format: PO-YYYY-NNNNN
expect(result).toMatch(/^PO-\d{4}-\d{5}$/);
});
it("generates unique values across calls", () => {
const numbers = new Set(Array.from({ length: 20 }, () => generatePoNumber()));
// Very unlikely to get a collision with 20 draws from 100000
expect(numbers.size).toBeGreaterThan(1);
});
});
describe("PO_STATUS_LABELS", () => {
it("maps every status to a non-empty label", () => {
const statuses = [
"DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING",
"EDITS_REQUESTED", "REJECTED", "MGR_APPROVED",
"SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED",
] as const;
for (const s of statuses) {
expect(PO_STATUS_LABELS[s]).toBeTruthy();
}
});
});
describe("PO_STATUS_VARIANTS", () => {
it("assigns danger variant to REJECTED", () => {
expect(PO_STATUS_VARIANTS["REJECTED"]).toBe("danger");
});
it("assigns success variant to MGR_APPROVED", () => {
expect(PO_STATUS_VARIANTS["MGR_APPROVED"]).toBe("success");
});
it("assigns warning variant to EDITS_REQUESTED", () => {
expect(PO_STATUS_VARIANTS["EDITS_REQUESTED"]).toBe("warning");
});
it("assigns outline variant to DRAFT", () => {
expect(PO_STATUS_VARIANTS["DRAFT"]).toBe("outline");
});
});

View file

@ -0,0 +1,172 @@
import { describe, it, expect } from "vitest";
import { createPoSchema, lineItemSchema, TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
// ── lineItemSchema ────────────────────────────────────────────────────────────
describe("lineItemSchema", () => {
const validItem = { description: "Gear Oil", quantity: "10", unit: "L", unitPrice: "182" };
it("accepts a valid line item", () => {
const result = lineItemSchema.safeParse(validItem);
expect(result.success).toBe(true);
});
it("defaults gstRate to 0.18 when omitted", () => {
const result = lineItemSchema.safeParse(validItem);
expect(result.success && result.data.gstRate).toBeCloseTo(0.18);
});
it("accepts gstRate of 0 (zero-rated supply)", () => {
const result = lineItemSchema.safeParse({ ...validItem, gstRate: "0" });
expect(result.success && result.data.gstRate).toBe(0);
});
it("accepts all valid GST rates: 0, 0.05, 0.12, 0.18, 0.28", () => {
for (const rate of [0, 0.05, 0.12, 0.18, 0.28]) {
const r = lineItemSchema.safeParse({ ...validItem, gstRate: String(rate) });
expect(r.success).toBe(true);
}
});
it("rejects gstRate > 1", () => {
const result = lineItemSchema.safeParse({ ...validItem, gstRate: "1.5" });
expect(result.success).toBe(false);
});
it("rejects gstRate < 0", () => {
const result = lineItemSchema.safeParse({ ...validItem, gstRate: "-0.1" });
expect(result.success).toBe(false);
});
it("rejects zero or negative quantity", () => {
expect(lineItemSchema.safeParse({ ...validItem, quantity: "0" }).success).toBe(false);
expect(lineItemSchema.safeParse({ ...validItem, quantity: "-1" }).success).toBe(false);
});
it("rejects negative unit price", () => {
const result = lineItemSchema.safeParse({ ...validItem, unitPrice: "-10" });
expect(result.success).toBe(false);
});
it("allows zero unit price (donation/free item)", () => {
const result = lineItemSchema.safeParse({ ...validItem, unitPrice: "0" });
expect(result.success).toBe(true);
});
it("rejects missing description", () => {
const result = lineItemSchema.safeParse({ ...validItem, description: "" });
expect(result.success).toBe(false);
});
it("size is optional and omitted when empty", () => {
const withSize = lineItemSchema.safeParse({ ...validItem, size: "10mm" });
expect(withSize.success && withSize.data.size).toBe("10mm");
const noSize = lineItemSchema.safeParse(validItem);
expect(noSize.success && noSize.data.size).toBeUndefined();
});
});
// ── createPoSchema ────────────────────────────────────────────────────────────
const baseValidPo = {
title: "Test Purchase Order",
vesselId: "vessel-123",
accountId: "account-456",
lineItems: [{ description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" }],
};
describe("createPoSchema", () => {
it("accepts a minimal valid PO", () => {
const result = createPoSchema.safeParse(baseValidPo);
expect(result.success).toBe(true);
});
it("defaults currency to INR", () => {
const result = createPoSchema.safeParse(baseValidPo);
expect(result.success && result.data.currency).toBe("INR");
});
it("rejects missing title", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, title: "" });
expect(result.success).toBe(false);
});
it("rejects title longer than 200 characters", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, title: "x".repeat(201) });
expect(result.success).toBe(false);
});
it("rejects missing vesselId", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, vesselId: "" });
expect(result.success).toBe(false);
});
it("rejects empty lineItems array", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, lineItems: [] });
expect(result.success).toBe(false);
});
it("accepts multiple line items", () => {
const result = createPoSchema.safeParse({
...baseValidPo,
lineItems: [
{ description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" },
{ description: "Item B", quantity: "2", unit: "L", unitPrice: "150", gstRate: "0.12" },
],
});
expect(result.success).toBe(true);
});
it("accepts all TC structured fields as optional", () => {
const result = createPoSchema.safeParse({
...baseValidPo,
tcDelivery: "Within 3 days",
tcDispatch: "Freight on supplier",
tcInspection: "Required",
tcTransitInsurance: "Supplier's account",
tcPaymentTerms: "Net 30",
tcOthers: "No asbestos",
});
expect(result.success).toBe(true);
expect(result.success && result.data.tcDelivery).toBe("Within 3 days");
expect(result.success && result.data.tcPaymentTerms).toBe("Net 30");
});
it("accepts PI quotation and requisition fields", () => {
const result = createPoSchema.safeParse({
...baseValidPo,
piQuotationNo: "Verbal",
requisitionNo: "REQN-2026-001",
placeOfDelivery: "Navi Mumbai",
});
expect(result.success).toBe(true);
});
});
// ── Constants ─────────────────────────────────────────────────────────────────
describe("TC_FIXED_LINE", () => {
it("references purchase order number for communications", () => {
expect(TC_FIXED_LINE).toMatch(/purchase order/i);
expect(TC_FIXED_LINE).toMatch(/communications/i);
});
});
describe("TC_DEFAULTS", () => {
it("has all 6 required keys", () => {
const keys = ["tcDelivery", "tcDispatch", "tcInspection", "tcTransitInsurance", "tcPaymentTerms", "tcOthers"];
for (const k of keys) {
expect(TC_DEFAULTS).toHaveProperty(k);
expect((TC_DEFAULTS as Record<string, string>)[k]).toBeTruthy();
}
});
it("default delivery mentions days", () => {
expect(TC_DEFAULTS.tcDelivery).toMatch(/day/i);
});
it("default payment terms mentions days", () => {
expect(TC_DEFAULTS.tcPaymentTerms).toMatch(/day/i);
});
});