diff --git a/App/pelagia-portal/tests/e2e/accounts-payment.spec.ts b/App/pelagia-portal/tests/e2e/accounts-payment.spec.ts new file mode 100644 index 0000000..8d6bde5 --- /dev/null +++ b/App/pelagia-portal/tests/e2e/accounts-payment.spec.ts @@ -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(); +}); diff --git a/App/pelagia-portal/tests/e2e/auth.spec.ts b/App/pelagia-portal/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..8122a98 --- /dev/null +++ b/App/pelagia-portal/tests/e2e/auth.spec.ts @@ -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/); + }); +}); diff --git a/App/pelagia-portal/tests/e2e/manager-approvals.spec.ts b/App/pelagia-portal/tests/e2e/manager-approvals.spec.ts new file mode 100644 index 0000000..b9d05fc --- /dev/null +++ b/App/pelagia-portal/tests/e2e/manager-approvals.spec.ts @@ -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 { + 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/); +}); diff --git a/App/pelagia-portal/tests/e2e/po-export.spec.ts b/App/pelagia-portal/tests/e2e/po-export.spec.ts new file mode 100644 index 0000000..f2ee153 --- /dev/null +++ b/App/pelagia-portal/tests/e2e/po-export.spec.ts @@ -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 { + 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(); + } +}); diff --git a/App/pelagia-portal/tests/e2e/submitter-journey.spec.ts b/App/pelagia-portal/tests/e2e/submitter-journey.spec.ts new file mode 100644 index 0000000..60c08c5 --- /dev/null +++ b/App/pelagia-portal/tests/e2e/submitter-journey.spec.ts @@ -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/); +}); diff --git a/App/pelagia-portal/tests/integration/approval-actions.test.ts b/App/pelagia-portal/tests/integration/approval-actions.test.ts new file mode 100644 index 0000000..b8139ab --- /dev/null +++ b/App/pelagia-portal/tests/integration/approval-actions.test.ts @@ -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 { + 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"); + }); +}); diff --git a/App/pelagia-portal/tests/integration/create-po.test.ts b/App/pelagia-portal/tests/integration/create-po.test.ts new file mode 100644 index 0000000..22b0a74 --- /dev/null +++ b/App/pelagia-portal/tests/integration/create-po.test.ts @@ -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" }) + ); + }); +}); diff --git a/App/pelagia-portal/tests/integration/helpers.ts b/App/pelagia-portal/tests/integration/helpers.ts new file mode 100644 index 0000000..c5483a3 --- /dev/null +++ b/App/pelagia-portal/tests/integration/helpers.ts @@ -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): 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 } }, + }); +} diff --git a/App/pelagia-portal/tests/integration/payment-actions.test.ts b/App/pelagia-portal/tests/integration/payment-actions.test.ts new file mode 100644 index 0000000..b94ead1 --- /dev/null +++ b/App/pelagia-portal/tests/integration/payment-actions.test.ts @@ -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 { + 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"); + }); +}); diff --git a/App/pelagia-portal/tests/setup.ts b/App/pelagia-portal/tests/setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/App/pelagia-portal/tests/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/App/pelagia-portal/tests/unit/permissions.test.ts b/App/pelagia-portal/tests/unit/permissions.test.ts new file mode 100644 index 0000000..a8ade8b --- /dev/null +++ b/App/pelagia-portal/tests/unit/permissions.test.ts @@ -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(); + }); + }); +}); diff --git a/App/pelagia-portal/tests/unit/po-line-items-editor.test.tsx b/App/pelagia-portal/tests/unit/po-line-items-editor.test.tsx new file mode 100644 index 0000000..957d041 --- /dev/null +++ b/App/pelagia-portal/tests/unit/po-line-items-editor.test.tsx @@ -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(); + // Each row has a description input + expect(screen.getAllByPlaceholderText("Item description")).toHaveLength(1); + }); + + it("shows the initial description value", () => { + render(); + const input = screen.getByPlaceholderText("Item description") as HTMLInputElement; + expect(input.value).toBe("Test Item"); + }); + + it("shows the initial quantity value", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText("Test Item")).toBeInTheDocument(); + }); + + it("shows GST% column in read-only mode", () => { + render(); + expect(screen.getByText("18%")).toBeInTheDocument(); + }); + + it("shows taxable, GST, grand total rows in read-only footer", () => { + render(); + 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(); + 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(); + // The original price 80 should appear with line-through styling + const text = document.body.textContent ?? ""; + expect(text).toMatch(/80/); + expect(text).toMatch(/100/); + }); +}); diff --git a/App/pelagia-portal/tests/unit/po-state-machine.test.ts b/App/pelagia-portal/tests/unit/po-state-machine.test.ts new file mode 100644 index 0000000..e19fcff --- /dev/null +++ b/App/pelagia-portal/tests/unit/po-state-machine.test.ts @@ -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); + }); + }); +}); diff --git a/App/pelagia-portal/tests/unit/po-status-badge.test.tsx b/App/pelagia-portal/tests/unit/po-status-badge.test.tsx new file mode 100644 index 0000000..faa31ac --- /dev/null +++ b/App/pelagia-portal/tests/unit/po-status-badge.test.tsx @@ -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(); + expect(screen.getByText(PO_STATUS_LABELS[status])).toBeInTheDocument(); + }); + + it("renders 'Draft' for DRAFT status", () => { + render(); + expect(screen.getByText("Draft")).toBeInTheDocument(); + }); + + it("renders 'Approved' for MGR_APPROVED status", () => { + render(); + expect(screen.getByText("Approved")).toBeInTheDocument(); + }); + + it("renders 'Rejected' for REJECTED status", () => { + render(); + expect(screen.getByText("Rejected")).toBeInTheDocument(); + }); + + it("renders 'Under Review' for MGR_REVIEW status", () => { + render(); + expect(screen.getByText("Under Review")).toBeInTheDocument(); + }); + + it("renders 'Edits Requested' for EDITS_REQUESTED status", () => { + render(); + expect(screen.getByText("Edits Requested")).toBeInTheDocument(); + }); + + it("renders 'Vendor ID Pending' for VENDOR_ID_PENDING status", () => { + render(); + expect(screen.getByText("Vendor ID Pending")).toBeInTheDocument(); + }); + + it("renders exactly one badge element", () => { + const { container } = render(); + // Badge renders as a single span/div-like element + expect(container.firstChild).not.toBeNull(); + expect(container.children).toHaveLength(1); + }); +}); diff --git a/App/pelagia-portal/tests/unit/utils.test.ts b/App/pelagia-portal/tests/unit/utils.test.ts new file mode 100644 index 0000000..5ba3680 --- /dev/null +++ b/App/pelagia-portal/tests/unit/utils.test.ts @@ -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"); + }); +}); diff --git a/App/pelagia-portal/tests/unit/validations.test.ts b/App/pelagia-portal/tests/unit/validations.test.ts new file mode 100644 index 0000000..cf15975 --- /dev/null +++ b/App/pelagia-portal/tests/unit/validations.test.ts @@ -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)[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); + }); +});