# Pelagia Portal — Test Plan

**Version:** 1.0  
**Date:** 2026-05-09  
**Project:** Pelagia Marine Services PO Portal  
**Scope:** Unit, Integration, and E2E test coverage across all portal features

---

## 1. Overview

This document describes the testing strategy, scope, tooling, and coverage matrix for the Pelagia Portal. It is intended as the authoritative reference for what is tested, why, and how to run each layer.

The portal manages the full lifecycle of purchase orders: creation, submission, manager review, vendor assignment, payment, and receipt confirmation. Testing focuses on correctness of state transitions, permission enforcement, and data integrity.

---

## 2. Testing Stack

| Layer | Tool | Environment | Command |
|---|---|---|---|
| Unit | Vitest 2.x | jsdom | `pnpm test` |
| Integration | Vitest 2.x | Node (real DB) | `pnpm test:integration` |
| E2E | Playwright 1.49 | Chromium (dev server) | `pnpm test:e2e` |

**Key libraries:** `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`.

Unit tests live in `tests/unit/`. Integration tests live in `tests/integration/`. E2E specs live in `tests/e2e/`.

Integration tests run serially in a single fork (`poolOptions.forks.singleFork = true`) to avoid database conflicts. Each test suite cleans up its own data via `afterEach` using the `deletePosByTitle(PREFIX)` helper.

---

## 3. Test Data & Environment

### 3.1 Seeded Data (prisma/seed.ts)

| Entity | Records | Notes |
|---|---|---|
| Users | 5 | admin, manager, tech, accounts, manning |
| Vessels | 3 | MV Pelagia Star, MV Aegean Wind, MV Poseidon |
| Accounts | 3 | TECH-OPS, CREW-MGT, FUEL-BNK |
| Vendors | 12 | VND-0001 to VND-0012; VND-0003 and VND-0012 are unverified |
| Products | 25 | Spanning lubricants, filters, safety, rope, electrical, paint, navigation |

Re-run with `npx tsx prisma/seed.ts` before integration tests if the database is reset.

### 3.2 Authentication Mocking

Integration tests mock `@/auth` to inject a session without real credentials:

```typescript
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mocked(auth).mockResolvedValue(makeSession(userId, "MANAGER"));
```

`makeSession(userId, role)` is defined in `tests/integration/helpers.ts`.

### 3.3 Side-Effect Mocking

All integration and unit tests mock:
- `@/lib/notifier` — prevents email dispatch
- `next/cache` (`revalidatePath`) — avoids Next.js cache calls outside a server context

---

## 4. Coverage Matrix

### 4.1 Unit Tests

| File | Test File | Cases Covered |
|---|---|---|
| `lib/permissions.ts` | `tests/unit/permissions.test.ts` | All 7 roles × key permissions; `requirePermission` throws |
| `lib/po-state-machine.ts` | `tests/unit/po-state-machine.test.ts` | `canPerformAction`, `getTransition`, `requiresNote`, `getAvailableActions`; MANAGER/ACCOUNTS expansions |
| `lib/po-import-parser.ts` | `tests/unit/po-import-parser.test.ts` | `cellStr`, `cellNum`, `parseSheet` (real + synthetic), `parseWorkbook` |
| `lib/validations/po.ts` | `tests/unit/validations.test.ts` | `lineItemSchema`, `createPoSchema`, TC defaults |
| `components/po/po-line-items-editor.tsx` | `tests/unit/po-line-items-editor.test.tsx` | Edit mode, read-only mode, totals, add/remove |
| `components/po/po-status-badge.tsx` | `tests/unit/po-status-badge.test.tsx` | All status labels |
| `lib/utils.ts` | `tests/unit/utils.test.ts` | `formatCurrency`, `formatDate`, `generatePoNumber`, status maps |

### 4.2 Integration Tests

| Test File | Feature | Scenarios |
|---|---|---|
| `create-po.test.ts` | S-01, S-02, S-03 | Draft, submit, line items, totals, optional fields, notifications |
| `approval-actions.test.ts` | M-02, M-03, M-04, S-06, S-07 | Approve, reject, request edits, vendor ID flow, resubmit |
| `payment-actions.test.ts` | A-01, A-02 | Payment queue, mark paid |
| `discard-po.test.ts` | Discard draft | Owner, MANAGER, SUPERUSER can discard; ACCOUNTS and non-owners denied; status guard; cascade cleanup |
| `vendor-approval.test.ts` | Vendor gate + provide vendor ID | Approval blocked without vendor; ACCOUNTS can provide vendor ID; unverified vendor rejected; AUDITOR denied |
| `manager-po-creation.test.ts` | Manager creates POs | MANAGER can create, submit, discard; ACCOUNTS denied; role documented for self-approval |
| `products-search.test.ts` | Product search API | Auth, min-length validation, name/code/description search, case-insensitive, max 10, inactive excluded, Decimal serialised |
| `import-api.test.ts` | Excel import API | Auth (TECHNICAL/ACCOUNTS → 403), no file, invalid file, correct parse of Sample_PO.xlsx |

### 4.3 E2E Tests (Playwright)

| Spec File | Scenarios |
|---|---|
| `auth.spec.ts` | Login, redirect on bad creds, role badge, sign-out |
| `submitter-journey.spec.ts` | Create draft, add line items, submit, see status transitions |
| `manager-approvals.spec.ts` | Review PO, approve with/without note, reject, request edits |
| `accounts-payment.spec.ts` | Payment queue, process payment, confirm receipt |
| `po-export.spec.ts` | PDF and XLSX export buttons and content |

---

## 5. Permission Test Matrix

The table below documents every role's expected access to key operations. ✓ = allowed, ✗ = denied.

| Operation | TECHNICAL | MANNING | ACCOUNTS | MANAGER | SUPERUSER | AUDITOR | ADMIN |
|---|---|---|---|---|---|---|---|
| Create PO | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Submit PO | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Edit own draft | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Discard own draft | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Discard any draft | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Approve PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Reject PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Request edits | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
| Provide vendor ID | Own PO only | Own PO only | ✓ | ✓ | ✓ | ✗ | ✗ |
| Process payment | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ |
| Confirm receipt | Own PO only | Own PO only | ✗ | ✗ | ✓ | ✗ | ✗ |
| Manage vendors | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ |
| Manage products | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Import PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✓ |
| View analytics | ✗ | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ |

**Business rules tested explicitly:**
- A vendor must be assigned before a manager can approve a PO.
- Only verified vendors (those with a `vendorId` field) may be assigned via `provideVendorId`.
- Discarding is only possible on `DRAFT` status POs.

---

## 6. Feature-Level Test Scenarios

### F-01: PO Creation & Draft Management
| ID | Scenario | Type | File |
|---|---|---|---|
| S-01 | Create PO with multiple line items; verify totals | Integration | `create-po.test.ts` |
| S-02 | Save as draft; verify status = DRAFT | Integration | `create-po.test.ts` |
| S-02a | ACCOUNTS role denied creation | Integration | `create-po.test.ts` |
| S-02b | MANAGER can create and save a draft | Integration | `manager-po-creation.test.ts` |
| S-03 | Submit for approval; status = MGR_REVIEW | Integration | `create-po.test.ts` |
| S-04 | Discard draft by owner | Integration | `discard-po.test.ts` |
| S-04a | MANAGER discards any draft | Integration | `discard-po.test.ts` |
| S-04b | ACCOUNTS cannot discard | Integration | `discard-po.test.ts` |
| S-04c | Cannot discard a submitted PO | Integration | `discard-po.test.ts` |

### F-02: Approval Workflow
| ID | Scenario | Type | File |
|---|---|---|---|
| M-01 | Manager sees pending POs | E2E | `manager-approvals.spec.ts` |
| M-02 | Approve PO → MGR_APPROVED | Integration / E2E | `approval-actions.test.ts` |
| M-02a | Approve with note stores managerNote | Integration | `approval-actions.test.ts` |
| M-02b | Approval blocked — no vendor assigned | Integration | `vendor-approval.test.ts` |
| M-03 | Reject PO with note | Integration / E2E | `approval-actions.test.ts` |
| M-04 | Request edits → EDITS_REQUESTED | Integration | `approval-actions.test.ts` |
| M-04a | Request vendor ID → VENDOR_ID_PENDING | Integration | `approval-actions.test.ts` |
| M-04b | TECHNICAL denied approval | Integration | `approval-actions.test.ts` |

### F-03: Vendor ID Assignment
| ID | Scenario | Type | File |
|---|---|---|---|
| S-06 | TECHNICAL provides vendor ID on own PO | Integration | `approval-actions.test.ts` |
| S-06a | ACCOUNTS provides vendor ID | Integration | `vendor-approval.test.ts` |
| S-06b | Unverified vendor rejected | Integration | `vendor-approval.test.ts` |
| S-06c | AUDITOR cannot provide vendor ID | Integration | `vendor-approval.test.ts` |
| S-06d | Wrong status → error | Integration | `vendor-approval.test.ts` |

### F-04: Payment & Receipt
| ID | Scenario | Type | File |
|---|---|---|---|
| A-01 | Accounts processes payment | Integration / E2E | `payment-actions.test.ts` |
| A-02 | Mark as paid with reference | Integration / E2E | `payment-actions.test.ts` |

### F-05: Excel Import
| ID | Scenario | Type | File |
|---|---|---|---|
| I-01 | Parser extracts 1 line item from Sample_PO.xlsx | Unit | `po-import-parser.test.ts` |
| I-02 | T&C rows not included in line items | Unit | `po-import-parser.test.ts` |
| I-03 | Vendor name, PI quotation, place of delivery extracted | Unit | `po-import-parser.test.ts` |
| I-04 | GST rate > 1 normalised to fraction | Unit | `po-import-parser.test.ts` |
| I-05 | INSTRUCTIONS TO VENDORS row stops parsing | Unit | `po-import-parser.test.ts` |
| I-06 | TECHNICAL / ACCOUNTS denied (403) | Integration | `import-api.test.ts` |
| I-07 | Unauthenticated denied (401) | Integration | `import-api.test.ts` |
| I-08 | No file → 400 | Integration | `import-api.test.ts` |
| I-09 | Invalid binary → 400 | Integration | `import-api.test.ts` |
| I-10 | MANAGER receives parsed results (200) | Integration | `import-api.test.ts` |
| I-11 | Correct line item values in API response | Integration | `import-api.test.ts` |

### F-06: Product Fuzzy Search
| ID | Scenario | Type | File |
|---|---|---|---|
| P-01 | Unauthenticated → 401 | Integration | `products-search.test.ts` |
| P-02 | Query < 2 chars → empty array | Integration | `products-search.test.ts` |
| P-03 | Search by name substring | Integration | `products-search.test.ts` |
| P-04 | Search by product code | Integration | `products-search.test.ts` |
| P-05 | Search by description text | Integration | `products-search.test.ts` |
| P-06 | Case-insensitive matching | Integration | `products-search.test.ts` |
| P-07 | Max 10 results returned | Integration | `products-search.test.ts` |
| P-08 | lastPrice serialised as `number` not Prisma Decimal | Integration | `products-search.test.ts` |
| P-09 | Inactive products excluded | Integration | `products-search.test.ts` |

---

## 7. Known Gaps & Out-of-Scope Items

### Currently untested (acceptable gaps)
| Area | Reason |
|---|---|
| File upload to S3 / storage | Requires live AWS credentials; tested manually in staging |
| Email notification content | `notify()` is mocked; email body format tested via review |
| PDF/XLSX export content | Snapshot-tested manually; E2E checks endpoint responds |
| Receipt confirmation workflow | Happy path covered in E2E; integration test pending |
| Admin CRUD (users, vessels, accounts, products) | Standard CRUD; covered by E2E smoke tests |

### Out of scope
- Performance / load testing
- Accessibility (a11y) automated checks
- Cross-browser testing (Chromium only)
- Mobile viewport testing

---

## 8. Running the Tests

```bash
# All unit tests (fast, no DB needed)
pnpm test

# Unit tests in watch mode during development
pnpm test:watch

# Integration tests (requires seeded DB)
pnpm test:integration

# All unit + integration
pnpm test:all

# E2E tests (requires running dev server)
pnpm test:e2e

# E2E with interactive Playwright UI
pnpm test:e2e:ui
```

### Pre-requisites for integration tests
1. A PostgreSQL instance running and `.env` pointing to it (`DATABASE_URL`).
2. Schema applied: `npx prisma migrate deploy` (or `npx prisma db push` in dev).
3. Data seeded: `npx tsx prisma/seed.ts`.

### CI behaviour
Integration tests and E2E tests run on every PR. E2E tests retry twice on failure (`playwright.config.ts`). The `test:all` script is used for pre-merge validation.

---

## 9. Test Authorship Conventions

- **Naming:** `describe` blocks map to feature scenarios (e.g., `"S-02 — save as draft"`). `it` blocks describe the outcome, not the action.
- **Prefix isolation:** Every integration test uses a `PREFIX` constant (e.g., `"INTTEST_DISCARD_"`) and cleans up with `afterEach(() => deletePosByTitle(PREFIX))`.
- **No test interdependence:** Each test creates its own data. Tests must pass in isolation and in any order.
- **Negative tests first:** Each describe block should include at least one negative (denial/error) case before or after the happy path.
- **Avoid `any`:** Type assertions in tests should use `as { id: string }` or similar narrow casts, not `as any`.
