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:
parent
5cb8b228b1
commit
e07ce9bd02
16 changed files with 1918 additions and 0 deletions
115
App/pelagia-portal/tests/e2e/accounts-payment.spec.ts
Normal file
115
App/pelagia-portal/tests/e2e/accounts-payment.spec.ts
Normal 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();
|
||||
});
|
||||
50
App/pelagia-portal/tests/e2e/auth.spec.ts
Normal file
50
App/pelagia-portal/tests/e2e/auth.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
152
App/pelagia-portal/tests/e2e/manager-approvals.spec.ts
Normal file
152
App/pelagia-portal/tests/e2e/manager-approvals.spec.ts
Normal 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/);
|
||||
});
|
||||
122
App/pelagia-portal/tests/e2e/po-export.spec.ts
Normal file
122
App/pelagia-portal/tests/e2e/po-export.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
134
App/pelagia-portal/tests/e2e/submitter-journey.spec.ts
Normal file
134
App/pelagia-portal/tests/e2e/submitter-journey.spec.ts
Normal 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/);
|
||||
});
|
||||
235
App/pelagia-portal/tests/integration/approval-actions.test.ts
Normal file
235
App/pelagia-portal/tests/integration/approval-actions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
192
App/pelagia-portal/tests/integration/create-po.test.ts
Normal file
192
App/pelagia-portal/tests/integration/create-po.test.ts
Normal 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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
87
App/pelagia-portal/tests/integration/helpers.ts
Normal file
87
App/pelagia-portal/tests/integration/helpers.ts
Normal 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 } },
|
||||
});
|
||||
}
|
||||
147
App/pelagia-portal/tests/integration/payment-actions.test.ts
Normal file
147
App/pelagia-portal/tests/integration/payment-actions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
1
App/pelagia-portal/tests/setup.ts
Normal file
1
App/pelagia-portal/tests/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "@testing-library/jest-dom";
|
||||
57
App/pelagia-portal/tests/unit/permissions.test.ts
Normal file
57
App/pelagia-portal/tests/unit/permissions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
178
App/pelagia-portal/tests/unit/po-line-items-editor.test.tsx
Normal file
178
App/pelagia-portal/tests/unit/po-line-items-editor.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
112
App/pelagia-portal/tests/unit/po-state-machine.test.ts
Normal file
112
App/pelagia-portal/tests/unit/po-state-machine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
App/pelagia-portal/tests/unit/po-status-badge.test.tsx
Normal file
55
App/pelagia-portal/tests/unit/po-status-badge.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
109
App/pelagia-portal/tests/unit/utils.test.ts
Normal file
109
App/pelagia-portal/tests/unit/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
172
App/pelagia-portal/tests/unit/validations.test.ts
Normal file
172
App/pelagia-portal/tests/unit/validations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue