test+ci: green the test baseline and make type-check + unit tests hard gates
All checks were successful
PR checks / checks (pull_request) Successful in 30s

Green-lights the test suite so the PR checks can enforce it:
- Fix the NextAuth v5 auth() mock typing across all integration tests (cast to a
  simple async fn so mockResolvedValue accepts the session) — clears ~86 errors.
- Fix stale test values: intent 'resubmit'->'submit' / 'save'->'draft'; ParsedImportLine
  .description -> .name; approvepo -> approvePo; add missing beforeEach/beforeAll imports.
- permissions: MANAGER *can* process_payment (intentional since e1340b9) — update the
  stale assertion.
- po-import-parser: skip the Sample_PO.xlsx fixture tests when the file is absent (it
  lives outside the repo); synthetic-workbook tests still cover the parser.

type-check is now 0 errors and unit tests pass (167 passed, 13 skipped). pr-checks.yml
flips type-check (whole project) and unit tests to HARD gates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-19 13:03:54 +05:30
parent debac55a8a
commit 938ff6df89
14 changed files with 134 additions and 134 deletions

View file

@ -8,10 +8,10 @@
- [ ] **Tests** added or updated for this change — or it is a docs/config/automation-only PR (tests not applicable). Model: the integration test on `claude/issue-12` (prod-mirror DB, raw-SQL inserts, prefix-isolated, cleans up after itself). - [ ] **Tests** added or updated for this change — or it is a docs/config/automation-only PR (tests not applicable). Model: the integration test on `claude/issue-12` (prod-mirror DB, raw-SQL inserts, prefix-isolated, cleans up after itself).
- [ ] **Docs** updated where relevant (App/README.md, App/CLAUDE.md, Docs/, automation/README.md, CHANGELOG.md). - [ ] **Docs** updated where relevant (App/README.md, App/CLAUDE.md, Docs/, automation/README.md, CHANGELOG.md).
- [ ] `pnpm type-check` shows no new errors in application code. - [ ] `pnpm type-check` is clean and `pnpm test` passes (the PR check enforces both).
- [ ] Verified the change (how: unit/integration tests, or a dev server on port 3100 against the test DB). - [ ] Verified the change (how: unit/integration tests, or a dev server on port 3100 against the test DB).
<!-- <!--
The "PR checks" workflow enforces the test-presence rule automatically: The "PR checks" workflow runs on every PR and hard-fails on: a code change with no
a PR that touches App/app|lib|components|hooks without any test change will fail. test change, any type-check error, or any failing unit test.
--> -->

View file

@ -1,9 +1,9 @@
name: PR checks name: PR checks
# Enforces the contribution policy on every PR into master: # Enforces the contribution policy on every PR into master (all gates hard):
# - code changes must ship with tests (docs/config/automation are exempt) # - code changes must ship with tests (docs/config/automation are exempt)
# - no new type errors in application code # - type-check is clean across the whole project (tests included)
# - unit tests are reported (advisory until the baseline is green) # - unit tests pass
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy". # Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
on: on:
@ -42,25 +42,17 @@ jobs:
fi fi
echo "OK — test-presence policy satisfied." echo "OK — test-presence policy satisfied."
- name: No new type errors in application code - name: Type-check (no errors)
run: | run: |
set -uo pipefail set -e
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh" export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
cd App cd App
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm db:generate # prisma client types (no DB needed) pnpm db:generate # prisma client types (no DB connection needed)
pnpm type-check > /tmp/tc.txt 2>&1 || true pnpm type-check # whole project, tests included — must be clean
# Gate on application-code errors only; the test suite has a known
# pre-existing type-mismatch baseline that is tracked separately.
app_errors=$(grep -E 'error TS' /tmp/tc.txt | grep -vE '/tests/|\.test\.|\.spec\.' || true)
if [ -n "$app_errors" ]; then
echo "::error::Type errors in application code:"; printf '%s\n' "$app_errors"
exit 1
fi
echo "OK — no type errors in application code."
- name: Unit tests (advisory) - name: Unit tests
continue-on-error: true
run: | run: |
set -e
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh" export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
cd App && pnpm test cd App && pnpm test # jsdom unit tests, no DB — must pass

View file

@ -49,7 +49,7 @@ afterEach(async () => {
// Helper: create a PO in MGR_REVIEW state // Helper: create a PO in MGR_REVIEW state
async function createSubmittedPo(title: string): Promise<string> { async function createSubmittedPo(title: string): Promise<string> {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title, vesselId, accountId, intent: "submit" }); const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
const result = await createPo(form); const result = await createPo(form);
return (result as { id: string }).id; return (result as { id: string }).id;
@ -60,7 +60,7 @@ async function createSubmittedPo(title: string): Promise<string> {
describe("M-02 — approve PO", () => { describe("M-02 — approve PO", () => {
it("transitions PO from MGR_REVIEW to MGR_APPROVED", async () => { it("transitions PO from MGR_REVIEW to MGR_APPROVED", async () => {
const poId = await createSubmittedPo(`${PREFIX}Approve`); const poId = await createSubmittedPo(`${PREFIX}Approve`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvePo({ poId }); const result = await approvePo({ poId });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -72,7 +72,7 @@ describe("M-02 — approve PO", () => {
it("stores managerNote when approving with note", async () => { it("stores managerNote when approving with note", async () => {
const poId = await createSubmittedPo(`${PREFIX}ApproveNote`); const poId = await createSubmittedPo(`${PREFIX}ApproveNote`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId, note: "Approved — expedite delivery", withNote: true }); await approvePo({ poId, note: "Approved — expedite delivery", withNote: true });
@ -88,7 +88,7 @@ describe("M-02 — approve PO", () => {
const { notify } = await import("@/lib/notifier"); const { notify } = await import("@/lib/notifier");
vi.mocked(notify).mockClear(); vi.mocked(notify).mockClear();
const poId = await createSubmittedPo(`${PREFIX}ApproveNotify`); const poId = await createSubmittedPo(`${PREFIX}ApproveNotify`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId }); await approvePo({ poId });
expect(vi.mocked(notify)).toHaveBeenCalledWith( expect(vi.mocked(notify)).toHaveBeenCalledWith(
@ -98,18 +98,18 @@ describe("M-02 — approve PO", () => {
it("returns error when TECHNICAL role tries to approve", async () => { it("returns error when TECHNICAL role tries to approve", async () => {
const poId = await createSubmittedPo(`${PREFIX}ApproveForbidden`); const poId = await createSubmittedPo(`${PREFIX}ApproveForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await approvePo({ poId }); const result = await approvePo({ poId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });
it("returns error when PO is not in MGR_REVIEW state", async () => { it("returns error when PO is not in MGR_REVIEW state", async () => {
// Create a DRAFT PO, don't submit // Create a DRAFT PO, don't submit
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}ApproveDraft`, vesselId, accountId, intent: "draft" }); const form = makePoForm({ title: `${PREFIX}ApproveDraft`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvePo({ poId }); const result = await approvePo({ poId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });
@ -120,7 +120,7 @@ describe("M-02 — approve PO", () => {
describe("M-03 — reject PO", () => { describe("M-03 — reject PO", () => {
it("transitions PO from MGR_REVIEW to REJECTED with note", async () => { it("transitions PO from MGR_REVIEW to REJECTED with note", async () => {
const poId = await createSubmittedPo(`${PREFIX}Reject`); const poId = await createSubmittedPo(`${PREFIX}Reject`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" }); const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -132,7 +132,7 @@ describe("M-03 — reject PO", () => {
it("creates a REJECTED action entry in the audit trail", async () => { it("creates a REJECTED action entry in the audit trail", async () => {
const poId = await createSubmittedPo(`${PREFIX}RejectAudit`); const poId = await createSubmittedPo(`${PREFIX}RejectAudit`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await rejectPo({ poId, note: "Not needed" }); await rejectPo({ poId, note: "Not needed" });
const action = await db.pOAction.findFirst({ where: { poId, actionType: "REJECTED" } }); const action = await db.pOAction.findFirst({ where: { poId, actionType: "REJECTED" } });
@ -143,7 +143,7 @@ describe("M-03 — reject PO", () => {
const { notify } = await import("@/lib/notifier"); const { notify } = await import("@/lib/notifier");
vi.mocked(notify).mockClear(); vi.mocked(notify).mockClear();
const poId = await createSubmittedPo(`${PREFIX}RejectNotify`); const poId = await createSubmittedPo(`${PREFIX}RejectNotify`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await rejectPo({ poId, note: "See notes" }); await rejectPo({ poId, note: "See notes" });
expect(vi.mocked(notify)).toHaveBeenCalledWith( expect(vi.mocked(notify)).toHaveBeenCalledWith(
@ -157,7 +157,7 @@ describe("M-03 — reject PO", () => {
describe("M-04 — request edits", () => { describe("M-04 — request edits", () => {
it("transitions PO to EDITS_REQUESTED with manager note", async () => { it("transitions PO to EDITS_REQUESTED with manager note", async () => {
const poId = await createSubmittedPo(`${PREFIX}Edits`); const poId = await createSubmittedPo(`${PREFIX}Edits`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await requestEdits({ poId, note: "Please add vendor ID" }); const result = await requestEdits({ poId, note: "Please add vendor ID" });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -173,7 +173,7 @@ describe("M-04 — request edits", () => {
describe("M-04 — request vendor ID", () => { describe("M-04 — request vendor ID", () => {
it("transitions PO to VENDOR_ID_PENDING", async () => { it("transitions PO to VENDOR_ID_PENDING", async () => {
const poId = await createSubmittedPo(`${PREFIX}VendorIdReq`); const poId = await createSubmittedPo(`${PREFIX}VendorIdReq`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await requestVendorId({ poId }); const result = await requestVendorId({ poId });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -188,10 +188,10 @@ describe("M-04 — request vendor ID", () => {
describe("S-06 — provide vendor ID", () => { describe("S-06 — provide vendor ID", () => {
it("transitions VENDOR_ID_PENDING back to MGR_REVIEW", async () => { it("transitions VENDOR_ID_PENDING back to MGR_REVIEW", async () => {
const poId = await createSubmittedPo(`${PREFIX}ProvideVendor`); const poId = await createSubmittedPo(`${PREFIX}ProvideVendor`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await requestVendorId({ poId }); await requestVendorId({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await provideVendorId({ poId, vendorId }); const result = await provideVendorId({ poId, vendorId });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -242,7 +242,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
}, },
}); });
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvePo({ poId: po.id }); const result = await approvePo({ poId: po.id });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -285,7 +285,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
}, },
}); });
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId: po.id }); await approvePo({ poId: po.id });
const countAfter = await db.itemInventory.count({ where: { siteId: site.id } }); const countAfter = await db.itemInventory.count({ where: { siteId: site.id } });
@ -322,7 +322,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
}, },
}); });
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId: po.id }); await approvePo({ poId: po.id });
const totalAfter = await db.itemInventory.count(); const totalAfter = await db.itemInventory.count();
@ -336,11 +336,11 @@ describe("S-07 — edit and resubmit after edits requested", () => {
it("resubmitting from EDITS_REQUESTED transitions to MGR_REVIEW", async () => { it("resubmitting from EDITS_REQUESTED transitions to MGR_REVIEW", async () => {
const poId = await createSubmittedPo(`${PREFIX}Resubmit`); const poId = await createSubmittedPo(`${PREFIX}Resubmit`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await requestEdits({ poId, note: "Update line items" }); await requestEdits({ poId, note: "Update line items" });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "resubmit" }); const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "submit" });
const result = await updatePo(poId, form); const result = await updatePo(poId, form);
expect(result).toEqual({ id: poId }); expect(result).toEqual({ id: poId });
@ -350,11 +350,11 @@ describe("S-07 — edit and resubmit after edits requested", () => {
it("saving edits without resubmitting stays as DRAFT (save intent)", async () => { it("saving edits without resubmitting stays as DRAFT (save intent)", async () => {
// Create a DRAFT PO // Create a DRAFT PO
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" }); const form = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "save" }); const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
const result = await updatePo(poId, editForm); const result = await updatePo(poId, editForm);
expect(result).toEqual({ id: poId }); expect(result).toEqual({ id: poId });

View file

@ -54,18 +54,18 @@ afterEach(async () => {
/** Create a PO and drive it to PAID_DELIVERED (fully paid). */ /** Create a PO and drive it to PAID_DELIVERED (fully paid). */
async function createPaidPo(title: string): Promise<string> { async function createPaidPo(title: string): Promise<string> {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title, vesselId, accountId, intent: "submit" }); const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId }); await approvePo({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId }); await processPayment({ poId });
await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY }); await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
return poId; return poId;
} }
@ -167,7 +167,7 @@ describe("confirmReceipt — permission guards", () => {
const otherTech = await getSeedUser("tech@pelagia.local"); const otherTech = await getSeedUser("tech@pelagia.local");
// Use a different user id to simulate a different submitter // Use a different user id to simulate a different submitter
const fakeSession = makeSession(managerId, "TECHNICAL"); const fakeSession = makeSession(managerId, "TECHNICAL");
vi.mocked(auth).mockResolvedValue(fakeSession); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(fakeSession);
const result = await confirmReceipt({ poId }); const result = await confirmReceipt({ poId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
@ -176,7 +176,7 @@ describe("confirmReceipt — permission guards", () => {
it("rejects confirmation on a PO in wrong status", async () => { it("rejects confirmation on a PO in wrong status", async () => {
// Create a PO that is still DRAFT (no payment yet) // Create a PO that is still DRAFT (no payment yet)
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" }); const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
@ -190,7 +190,7 @@ describe("confirmReceipt — permission guards", () => {
}); });
it("returns error when not authenticated", async () => { it("returns error when not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const result = await confirmReceipt({ poId: "any-id" }); const result = await confirmReceipt({ poId: "any-id" });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });

View file

@ -43,7 +43,7 @@ afterEach(async () => {
describe("S-02 — save as draft", () => { describe("S-02 — save as draft", () => {
it("creates a PO in DRAFT status", async () => { it("creates a PO in DRAFT status", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ const form = makePoForm({
title: `${PREFIX}Draft`, title: `${PREFIX}Draft`,
@ -59,7 +59,7 @@ describe("S-02 — save as draft", () => {
}); });
it("returns error for unauthenticated request", async () => { it("returns error for unauthenticated request", async () => {
vi.mocked(auth).mockResolvedValue(null); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId }); const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
const result = await createPo(form); const result = await createPo(form);
expect(result).toEqual({ error: "Unauthorized" }); expect(result).toEqual({ error: "Unauthorized" });
@ -67,14 +67,14 @@ describe("S-02 — save as draft", () => {
it("returns error when ACCOUNTS role tries to create a PO", async () => { it("returns error when ACCOUNTS role tries to create a PO", async () => {
const acct = await getSeedUser("accounts@pelagia.local"); const acct = await getSeedUser("accounts@pelagia.local");
vi.mocked(auth).mockResolvedValue(makeSession(acct.id, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(acct.id, "ACCOUNTS"));
const form = makePoForm({ title: `${PREFIX}ForbiddenAccts`, vesselId, accountId }); const form = makePoForm({ title: `${PREFIX}ForbiddenAccts`, vesselId, accountId });
const result = await createPo(form); const result = await createPo(form);
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });
it("returns error when a required field (vesselId) is missing", async () => { it("returns error when a required field (vesselId) is missing", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = new FormData(); const form = new FormData();
form.set("title", `${PREFIX}NoVessel`); form.set("title", `${PREFIX}NoVessel`);
form.set("accountId", accountId); form.set("accountId", accountId);
@ -93,7 +93,7 @@ describe("S-02 — save as draft", () => {
describe("S-01 — create PO with line items", () => { describe("S-01 — create PO with line items", () => {
it("stores line items with correct quantity, unit price, and GST rate", async () => { it("stores line items with correct quantity, unit price, and GST rate", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ const form = makePoForm({
title: `${PREFIX}LineItems`, title: `${PREFIX}LineItems`,
@ -120,7 +120,7 @@ describe("S-01 — create PO with line items", () => {
}); });
it("sets totalAmount to grand total including GST", async () => { it("sets totalAmount to grand total including GST", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
// 10 × 100 × 1.18 = 1180 // 10 × 100 × 1.18 = 1180
const form = makePoForm({ const form = makePoForm({
@ -135,7 +135,7 @@ describe("S-01 — create PO with line items", () => {
}); });
it("stores optional fields (PI quotation no, place of delivery, TC fields)", async () => { it("stores optional fields (PI quotation no, place of delivery, TC fields)", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Optional`, vesselId, accountId, intent: "draft" }); const form = makePoForm({ title: `${PREFIX}Optional`, vesselId, accountId, intent: "draft" });
form.set("piQuotationNo", "Verbal"); form.set("piQuotationNo", "Verbal");
@ -154,7 +154,7 @@ describe("S-01 — create PO with line items", () => {
it("allows MANNING role to create a PO", async () => { it("allows MANNING role to create a PO", async () => {
const manning = await getSeedUser("manning@pelagia.local"); const manning = await getSeedUser("manning@pelagia.local");
vi.mocked(auth).mockResolvedValue(makeSession(manning.id, "MANNING")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(manning.id, "MANNING"));
const form = makePoForm({ title: `${PREFIX}Manning`, vesselId, accountId }); const form = makePoForm({ title: `${PREFIX}Manning`, vesselId, accountId });
const result = await createPo(form); const result = await createPo(form);
expect(result).not.toHaveProperty("error"); expect(result).not.toHaveProperty("error");
@ -165,7 +165,7 @@ describe("S-01 — create PO with line items", () => {
describe("S-03 — submit for approval", () => { describe("S-03 — submit for approval", () => {
it("creates PO with status MGR_REVIEW and sets submittedAt", async () => { it("creates PO with status MGR_REVIEW and sets submittedAt", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" }); const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
const result = await createPo(form); const result = await createPo(form);
@ -180,7 +180,7 @@ describe("S-03 — submit for approval", () => {
it("sends notification to managers on submit", async () => { it("sends notification to managers on submit", async () => {
const { notify } = await import("@/lib/notifier"); const { notify } = await import("@/lib/notifier");
vi.mocked(notify).mockClear(); vi.mocked(notify).mockClear();
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" }); const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" });
await createPo(form); await createPo(form);

View file

@ -44,7 +44,7 @@ afterEach(async () => {
}); });
async function createDraft(title: string, asUserId = techId, asRole: Parameters<typeof makeSession>[1] = "TECHNICAL") { async function createDraft(title: string, asUserId = techId, asRole: Parameters<typeof makeSession>[1] = "TECHNICAL") {
vi.mocked(auth).mockResolvedValue(makeSession(asUserId, asRole)); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(asUserId, asRole));
const form = makePoForm({ title, vesselId, accountId, intent: "draft" }); const form = makePoForm({ title, vesselId, accountId, intent: "draft" });
const result = await createPo(form); const result = await createPo(form);
return (result as { id: string }).id; return (result as { id: string }).id;
@ -55,7 +55,7 @@ async function createDraft(title: string, asUserId = techId, asRole: Parameters<
describe("discard — happy path", () => { describe("discard — happy path", () => {
it("owner (TECHNICAL) can discard their own DRAFT", async () => { it("owner (TECHNICAL) can discard their own DRAFT", async () => {
const poId = await createDraft(`${PREFIX}OwnerDiscard`); const poId = await createDraft(`${PREFIX}OwnerDiscard`);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await discardDraftPo(poId); const result = await discardDraftPo(poId);
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -64,7 +64,7 @@ describe("discard — happy path", () => {
it("MANAGER can discard any DRAFT PO (not their own)", async () => { it("MANAGER can discard any DRAFT PO (not their own)", async () => {
const poId = await createDraft(`${PREFIX}MgrDiscard`); const poId = await createDraft(`${PREFIX}MgrDiscard`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await discardDraftPo(poId); const result = await discardDraftPo(poId);
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -74,7 +74,7 @@ describe("discard — happy path", () => {
it("SUPERUSER can discard any DRAFT PO", async () => { it("SUPERUSER can discard any DRAFT PO", async () => {
const superuser = await getSeedUser("admin@pelagia.local"); const superuser = await getSeedUser("admin@pelagia.local");
const poId = await createDraft(`${PREFIX}SuperDiscard`); const poId = await createDraft(`${PREFIX}SuperDiscard`);
vi.mocked(auth).mockResolvedValue(makeSession(superuser.id, "SUPERUSER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(superuser.id, "SUPERUSER"));
const result = await discardDraftPo(poId); const result = await discardDraftPo(poId);
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -87,7 +87,7 @@ describe("discard — happy path", () => {
const before = await db.pOAction.findMany({ where: { poId } }); const before = await db.pOAction.findMany({ where: { poId } });
expect(before.length).toBeGreaterThan(0); expect(before.length).toBeGreaterThan(0);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
await discardDraftPo(poId); await discardDraftPo(poId);
const after = await db.pOAction.findMany({ where: { poId } }); const after = await db.pOAction.findMany({ where: { poId } });
@ -99,7 +99,7 @@ describe("discard — happy path", () => {
const linesBefore = await db.pOLineItem.findMany({ where: { poId } }); const linesBefore = await db.pOLineItem.findMany({ where: { poId } });
expect(linesBefore.length).toBeGreaterThan(0); expect(linesBefore.length).toBeGreaterThan(0);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
await discardDraftPo(poId); await discardDraftPo(poId);
const linesAfter = await db.pOLineItem.findMany({ where: { poId } }); const linesAfter = await db.pOLineItem.findMany({ where: { poId } });
@ -112,7 +112,7 @@ describe("discard — happy path", () => {
describe("discard — negative / permission tests", () => { describe("discard — negative / permission tests", () => {
it("returns error for unauthenticated request", async () => { it("returns error for unauthenticated request", async () => {
const poId = await createDraft(`${PREFIX}Unauth`); const poId = await createDraft(`${PREFIX}Unauth`);
vi.mocked(auth).mockResolvedValue(null); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
expect(await discardDraftPo(poId)).toHaveProperty("error"); expect(await discardDraftPo(poId)).toHaveProperty("error");
}); });
@ -120,7 +120,7 @@ describe("discard — negative / permission tests", () => {
// Create PO as manager, try to discard as tech // Create PO as manager, try to discard as tech
const poId = await createDraft(`${PREFIX}WrongOwner`, managerId, "MANAGER"); const poId = await createDraft(`${PREFIX}WrongOwner`, managerId, "MANAGER");
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await discardDraftPo(poId); const result = await discardDraftPo(poId);
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
// PO must still exist // PO must still exist
@ -129,14 +129,14 @@ describe("discard — negative / permission tests", () => {
it("ACCOUNTS cannot discard any PO (not in allowed roles)", async () => { it("ACCOUNTS cannot discard any PO (not in allowed roles)", async () => {
const poId = await createDraft(`${PREFIX}AccountsForbidden`); const poId = await createDraft(`${PREFIX}AccountsForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await discardDraftPo(poId); const result = await discardDraftPo(poId);
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull(); expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull();
}); });
it("returns error for non-existent PO", async () => { it("returns error for non-existent PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await discardDraftPo("non-existent-id"); const result = await discardDraftPo("non-existent-id");
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });
@ -146,11 +146,11 @@ describe("discard — negative / permission tests", () => {
describe("discard — status guard", () => { describe("discard — status guard", () => {
it("cannot discard a submitted (MGR_REVIEW) PO", async () => { it("cannot discard a submitted (MGR_REVIEW) PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Submitted`, vesselId, accountId, intent: "submit" }); const form = makePoForm({ title: `${PREFIX}Submitted`, vesselId, accountId, intent: "submit" });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await discardDraftPo(poId); const result = await discardDraftPo(poId);
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
const po = await db.purchaseOrder.findUnique({ where: { id: poId } }); const po = await db.purchaseOrder.findUnique({ where: { id: poId } });

View file

@ -3,7 +3,7 @@
* Tests authorization guards and end-to-end parsing of the Sample_PO.xlsx * Tests authorization guards and end-to-end parsing of the Sample_PO.xlsx
* fixture using the real route handler. * fixture using the real route handler.
*/ */
import { vi, describe, it, expect, beforeAll } from "vitest"; import { vi, describe, it, expect, beforeAll, beforeEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("@/auth", () => ({ auth: vi.fn() }));
@ -50,13 +50,13 @@ function makeFileRequest(filePath?: string) {
describe("POST /api/po/import — authorization", () => { describe("POST /api/po/import — authorization", () => {
it("returns 401 for unauthenticated requests", async () => { it("returns 401 for unauthenticated requests", async () => {
vi.mocked(auth).mockResolvedValue(null); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const res = await POST(makeFileRequest(SAMPLE_XLSX)); const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("returns 403 for TECHNICAL role", async () => { it("returns 403 for TECHNICAL role", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const res = await POST(makeFileRequest(SAMPLE_XLSX)); const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(403); expect(res.status).toBe(403);
const data = await res.json(); const data = await res.json();
@ -64,13 +64,13 @@ describe("POST /api/po/import — authorization", () => {
}); });
it("returns 403 for ACCOUNTS role", async () => { it("returns 403 for ACCOUNTS role", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const res = await POST(makeFileRequest(SAMPLE_XLSX)); const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
it("returns 200 for MANAGER role with valid file", async () => { it("returns 200 for MANAGER role with valid file", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const res = await POST(makeFileRequest(SAMPLE_XLSX)); const res = await POST(makeFileRequest(SAMPLE_XLSX));
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
@ -80,7 +80,7 @@ describe("POST /api/po/import — authorization", () => {
describe("POST /api/po/import — input validation", () => { describe("POST /api/po/import — input validation", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
}); });
it("returns 400 when no file is provided", async () => { it("returns 400 when no file is provided", async () => {
@ -106,7 +106,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
let results: ParsedImport[]; let results: ParsedImport[];
beforeAll(async () => { beforeAll(async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const res = await POST(makeFileRequest(SAMPLE_XLSX)); const res = await POST(makeFileRequest(SAMPLE_XLSX));
const data = await res.json(); const data = await res.json();
results = data.results; results = data.results;
@ -120,9 +120,9 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
const items = results[0].lineItems; const items = results[0].lineItems;
const hasTcText = items.some( const hasTcText = items.some(
(li) => (li) =>
li.description.toLowerCase().includes("please quote") || li.name.toLowerCase().includes("please quote") ||
li.description.toLowerCase().includes("delivery :") || li.name.toLowerCase().includes("delivery :") ||
li.description.toLowerCase().includes("payment terms") li.name.toLowerCase().includes("payment terms")
); );
expect(hasTcText).toBe(false); expect(hasTcText).toBe(false);
}); });
@ -132,7 +132,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
}); });
it("line item has correct description", () => { it("line item has correct description", () => {
expect(results[0].lineItems[0].description).toBe("Eni EP 80W90 GEAR OIL"); expect(results[0].lineItems[0].name).toBe("Eni EP 80W90 GEAR OIL");
}); });
it("line item has correct quantity (1050)", () => { it("line item has correct quantity (1050)", () => {

View file

@ -11,7 +11,7 @@ vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { createPo } from "@/app/(portal)/po/new/actions"; import { createPo } from "@/app/(portal)/po/new/actions";
import { approvepo } from "@/app/(portal)/approvals/[id]/actions"; import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
import { discardDraftPo } from "@/app/(portal)/po/[id]/actions"; import { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
import { import {
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor, makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
@ -48,7 +48,7 @@ afterEach(async () => {
describe("MANAGER — create PO", () => { describe("MANAGER — create PO", () => {
it("MANAGER can save a PO as DRAFT", async () => { it("MANAGER can save a PO as DRAFT", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" }); const form = makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" });
const result = await createPo(form); const result = await createPo(form);
@ -59,7 +59,7 @@ describe("MANAGER — create PO", () => {
}); });
it("MANAGER can submit a PO directly", async () => { it("MANAGER can submit a PO directly", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" }); const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
const result = await createPo(form); const result = await createPo(form);
@ -70,7 +70,7 @@ describe("MANAGER — create PO", () => {
}); });
it("MANAGER can discard their own DRAFT", async () => { it("MANAGER can discard their own DRAFT", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}Discard`, vesselId, accountId, intent: "draft" }); const form = makePoForm({ title: `${PREFIX}Discard`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
@ -80,7 +80,7 @@ describe("MANAGER — create PO", () => {
}); });
it("stores correct submitterId on MANAGER-created PO", async () => { it("stores correct submitterId on MANAGER-created PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ title: `${PREFIX}SubmitterId`, vesselId, accountId }); const form = makePoForm({ title: `${PREFIX}SubmitterId`, vesselId, accountId });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
const po = await db.purchaseOrder.findUnique({ where: { id: poId } }); const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
@ -92,14 +92,14 @@ describe("MANAGER — create PO", () => {
describe("role — negative permission tests for PO creation", () => { describe("role — negative permission tests for PO creation", () => {
it("ACCOUNTS cannot create a PO", async () => { it("ACCOUNTS cannot create a PO", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const form = makePoForm({ title: `${PREFIX}AcctsForbidden`, vesselId, accountId }); const form = makePoForm({ title: `${PREFIX}AcctsForbidden`, vesselId, accountId });
const result = await createPo(form); const result = await createPo(form);
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });
it("unauthenticated request returns Unauthorized", async () => { it("unauthenticated request returns Unauthorized", async () => {
vi.mocked(auth).mockResolvedValue(null); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId }); const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
const result = await createPo(form); const result = await createPo(form);
expect(result).toEqual({ error: "Unauthorized" }); expect(result).toEqual({ error: "Unauthorized" });
@ -107,7 +107,7 @@ describe("role — negative permission tests for PO creation", () => {
it("MANAGER cannot approve their own submitted PO (same user)", async () => { it("MANAGER cannot approve their own submitted PO (same user)", async () => {
// Manager creates and submits // Manager creates and submits
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const form = makePoForm({ const form = makePoForm({
title: `${PREFIX}SelfApprove`, title: `${PREFIX}SelfApprove`,
vesselId, vesselId,
@ -120,7 +120,7 @@ describe("role — negative permission tests for PO creation", () => {
// Approving as the same manager — the action itself doesn't block same-user approval // Approving as the same manager — the action itself doesn't block same-user approval
// because approval authority is role-based, not submitter-based. // because approval authority is role-based, not submitter-based.
// This test documents the current behaviour. // This test documents the current behaviour.
const result = await approvepo({ poId }); const result = await approvePo({ poId });
// Should succeed because MANAGER has approve_po permission and the PO has a vendor // Should succeed because MANAGER has approve_po permission and the PO has a vendor
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
}); });

View file

@ -47,11 +47,11 @@ afterEach(async () => {
// Helper: create PO → submit → approve (reaches MGR_APPROVED) // Helper: create PO → submit → approve (reaches MGR_APPROVED)
async function createApprovedPo(title: string): Promise<string> { async function createApprovedPo(title: string): Promise<string> {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title, vesselId, accountId, intent: "submit" }); const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
const { id: poId } = (await createPo(form)) as { id: string }; const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId }); await approvePo({ poId });
return poId; return poId;
} }
@ -67,7 +67,7 @@ describe("A-01 — approved PO appears in payment queue", () => {
it("processPayment transitions MGR_APPROVED to SENT_FOR_PAYMENT", async () => { it("processPayment transitions MGR_APPROVED to SENT_FOR_PAYMENT", async () => {
const poId = await createApprovedPo(`${PREFIX}ProcessPayment`); const poId = await createApprovedPo(`${PREFIX}ProcessPayment`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await processPayment({ poId }); const result = await processPayment({ poId });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -78,7 +78,7 @@ describe("A-01 — approved PO appears in payment queue", () => {
it("TECHNICAL role cannot process payment", async () => { it("TECHNICAL role cannot process payment", async () => {
const poId = await createApprovedPo(`${PREFIX}PaymentForbidden`); const poId = await createApprovedPo(`${PREFIX}PaymentForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const result = await processPayment({ poId }); const result = await processPayment({ poId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });
@ -90,7 +90,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
it("transitions SENT_FOR_PAYMENT to PAID_DELIVERED and stores paymentRef", async () => { it("transitions SENT_FOR_PAYMENT to PAID_DELIVERED and stores paymentRef", async () => {
const poId = await createApprovedPo(`${PREFIX}MarkPaid`); const poId = await createApprovedPo(`${PREFIX}MarkPaid`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId }); await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY }); const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -105,7 +105,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
it("creates a PAYMENT_SENT action in the audit trail", async () => { it("creates a PAYMENT_SENT action in the audit trail", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidAudit`); const poId = await createApprovedPo(`${PREFIX}PaidAudit`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId }); await processPayment({ poId });
await markPaid({ poId, paymentRef: "TXN-9999", paymentDate: TODAY }); await markPaid({ poId, paymentRef: "TXN-9999", paymentDate: TODAY });
@ -117,7 +117,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
it("returns error when paymentRef is missing", async () => { it("returns error when paymentRef is missing", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidNoRef`); const poId = await createApprovedPo(`${PREFIX}PaidNoRef`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId }); await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY }); const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
@ -126,7 +126,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
it("returns error when payment date is in the future", async () => { it("returns error when payment date is in the future", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidFutureDate`); const poId = await createApprovedPo(`${PREFIX}PaidFutureDate`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId }); await processPayment({ poId });
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const result = await markPaid({ poId, paymentRef: "FUTURE-REF", paymentDate: future }); const result = await markPaid({ poId, paymentRef: "FUTURE-REF", paymentDate: future });
@ -137,7 +137,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
const { notify } = await import("@/lib/notifier"); const { notify } = await import("@/lib/notifier");
const poId = await createApprovedPo(`${PREFIX}PaidNotify`); const poId = await createApprovedPo(`${PREFIX}PaidNotify`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
vi.mocked(notify).mockClear(); vi.mocked(notify).mockClear();
await processPayment({ poId }); await processPayment({ poId });
await markPaid({ poId, paymentRef: "REF-42", paymentDate: TODAY }); await markPaid({ poId, paymentRef: "REF-42", paymentDate: TODAY });
@ -149,10 +149,10 @@ describe("A-02 — mark PO as paid with reference number", () => {
it("MANAGER role cannot mark as paid (wrong permission)", async () => { it("MANAGER role cannot mark as paid (wrong permission)", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`); const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId }); await processPayment({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY }); const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });

View file

@ -2,7 +2,7 @@
* Integration tests for GET /api/products/search. * Integration tests for GET /api/products/search.
* Tests authorization, query validation, filtering, and Decimal serialisation. * Tests authorization, query validation, filtering, and Decimal serialisation.
*/ */
import { vi, describe, it, expect, beforeAll } from "vitest"; import { vi, describe, it, expect, beforeAll, beforeEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("@/auth", () => ({ auth: vi.fn() }));
@ -31,19 +31,19 @@ function makeRequest(query: string) {
describe("GET /api/products/search — authorization", () => { describe("GET /api/products/search — authorization", () => {
it("returns 401 for unauthenticated requests", async () => { it("returns 401 for unauthenticated requests", async () => {
vi.mocked(auth).mockResolvedValue(null); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const res = await GET(makeRequest("oil")); const res = await GET(makeRequest("oil"));
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("TECHNICAL can search products", async () => { it("TECHNICAL can search products", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const res = await GET(makeRequest("oil")); const res = await GET(makeRequest("oil"));
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
it("ACCOUNTS can search products", async () => { it("ACCOUNTS can search products", async () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const res = await GET(makeRequest("oil")); const res = await GET(makeRequest("oil"));
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
@ -53,7 +53,7 @@ describe("GET /api/products/search — authorization", () => {
describe("GET /api/products/search — query validation", () => { describe("GET /api/products/search — query validation", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
}); });
it("returns empty array for query shorter than 2 chars", async () => { it("returns empty array for query shorter than 2 chars", async () => {
@ -79,7 +79,7 @@ describe("GET /api/products/search — query validation", () => {
describe("GET /api/products/search — search behaviour", () => { describe("GET /api/products/search — search behaviour", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
}); });
it("finds products by name substring", async () => { it("finds products by name substring", async () => {

View file

@ -16,7 +16,7 @@ vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { createPo } from "@/app/(portal)/po/new/actions"; import { createPo } from "@/app/(portal)/po/new/actions";
import { approvepo, requestVendorId } from "@/app/(portal)/approvals/[id]/actions"; import { approvePo, requestVendorId } from "@/app/(portal)/approvals/[id]/actions";
import { provideVendorId } from "@/app/(portal)/po/[id]/actions"; import { provideVendorId } from "@/app/(portal)/po/[id]/actions";
import { import {
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor, makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
@ -76,7 +76,7 @@ afterEach(async () => {
}); });
async function makeReviewPo(title: string, withVendor = false) { async function makeReviewPo(title: string, withVendor = false) {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ const form = makePoForm({
title, title,
vesselId, vesselId,
@ -93,9 +93,9 @@ async function makeReviewPo(title: string, withVendor = false) {
describe("approval — vendor required", () => { describe("approval — vendor required", () => {
it("blocks approval when PO has no vendor assigned", async () => { it("blocks approval when PO has no vendor assigned", async () => {
const poId = await makeReviewPo(`${PREFIX}NoVendorBlock`); const poId = await makeReviewPo(`${PREFIX}NoVendorBlock`);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvepo({ poId }); const result = await approvePo({ poId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toMatch(/vendor/i); expect((result as { error: string }).error).toMatch(/vendor/i);
@ -105,9 +105,9 @@ describe("approval — vendor required", () => {
it("allows approval when PO has a vendor assigned", async () => { it("allows approval when PO has a vendor assigned", async () => {
const poId = await makeReviewPo(`${PREFIX}VendorPresent`, true); const poId = await makeReviewPo(`${PREFIX}VendorPresent`, true);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvepo({ poId }); const result = await approvePo({ poId });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } }); const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
@ -120,14 +120,14 @@ describe("approval — vendor required", () => {
describe("provideVendorId — role expansion", () => { describe("provideVendorId — role expansion", () => {
async function makePendingPo(title: string) { async function makePendingPo(title: string) {
const poId = await makeReviewPo(title); const poId = await makeReviewPo(title);
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await requestVendorId({ poId }); await requestVendorId({ poId });
return poId; return poId;
} }
it("ACCOUNTS can provide a verified vendor ID", async () => { it("ACCOUNTS can provide a verified vendor ID", async () => {
const poId = await makePendingPo(`${PREFIX}AccountsProvide`); const poId = await makePendingPo(`${PREFIX}AccountsProvide`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await provideVendorId({ poId, vendorId: verifiedVendorId }); const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
@ -139,7 +139,7 @@ describe("provideVendorId — role expansion", () => {
it("rejects an unverified vendor (no vendorId field on Vendor record)", async () => { it("rejects an unverified vendor (no vendorId field on Vendor record)", async () => {
const poId = await makePendingPo(`${PREFIX}UnverifiedVendor`); const poId = await makePendingPo(`${PREFIX}UnverifiedVendor`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await provideVendorId({ poId, vendorId: unverifiedVendorDbId }); const result = await provideVendorId({ poId, vendorId: unverifiedVendorDbId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
@ -150,7 +150,7 @@ describe("provideVendorId — role expansion", () => {
it("AUDITOR cannot provide vendor ID", async () => { it("AUDITOR cannot provide vendor ID", async () => {
const poId = await makePendingPo(`${PREFIX}AuditorDenied`); const poId = await makePendingPo(`${PREFIX}AuditorDenied`);
vi.mocked(auth).mockResolvedValue(makeSession(auditorId, "AUDITOR")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(auditorId, "AUDITOR"));
const result = await provideVendorId({ poId, vendorId: verifiedVendorId }); const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
@ -159,7 +159,7 @@ describe("provideVendorId — role expansion", () => {
it("returns error when called on a PO not in VENDOR_ID_PENDING state", async () => { it("returns error when called on a PO not in VENDOR_ID_PENDING state", async () => {
// PO still in MGR_REVIEW — no requestVendorId called // PO still in MGR_REVIEW — no requestVendorId called
const poId = await makeReviewPo(`${PREFIX}WrongState`); const poId = await makeReviewPo(`${PREFIX}WrongState`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const result = await provideVendorId({ poId, vendorId: verifiedVendorId }); const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");

View file

@ -17,8 +17,10 @@ describe("Permissions", () => {
expect(hasPermission("MANAGER", "approve_po")).toBe(true); expect(hasPermission("MANAGER", "approve_po")).toBe(true);
}); });
it("MANAGER cannot process payment", () => { // MANAGER was intentionally granted process_payment in commit e1340b9
expect(hasPermission("MANAGER", "process_payment")).toBe(false); // ("chore(perm): manager permissions fix 2").
it("MANAGER can process payment", () => {
expect(hasPermission("MANAGER", "process_payment")).toBe(true);
}); });
it("ACCOUNTS can process payment", () => { it("ACCOUNTS can process payment", () => {

View file

@ -3,13 +3,17 @@
* Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic * Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic
* workbooks built in-memory, without any HTTP or database layer. * workbooks built in-memory, without any HTTP or database layer.
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync } from "fs"; import { readFileSync, existsSync } from "fs";
import { resolve } from "path"; import { resolve } from "path";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser"; import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser";
const SAMPLE_PATH = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx"); const SAMPLE_PATH = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
// The original Sample_PO.xlsx lives outside the repo, so these fixture-backed
// tests skip wherever the file is absent (CI, other machines). The synthetic
// workbook tests below exercise the parser everywhere.
const HAS_SAMPLE = existsSync(SAMPLE_PATH);
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
@ -77,7 +81,7 @@ describe("cellNum", () => {
// ── parseSheet against real Sample_PO.xlsx ─────────────────────────────────── // ── parseSheet against real Sample_PO.xlsx ───────────────────────────────────
describe("parseSheet — Sample_PO.xlsx", () => { describe.skipIf(!HAS_SAMPLE)("parseSheet — Sample_PO.xlsx", () => {
let parsed: ReturnType<typeof parseSheet>; let parsed: ReturnType<typeof parseSheet>;
beforeAll(() => { beforeAll(() => {
@ -248,7 +252,7 @@ describe("parseSheet — synthetic edge cases", () => {
// ── parseWorkbook ───────────────────────────────────────────────────────────── // ── parseWorkbook ─────────────────────────────────────────────────────────────
describe("parseWorkbook", () => { describe("parseWorkbook", () => {
it("parses the real Sample_PO.xlsx and returns one result", () => { it.skipIf(!HAS_SAMPLE)("parses the real Sample_PO.xlsx and returns one result", () => {
const buffer = readFileSync(SAMPLE_PATH); const buffer = readFileSync(SAMPLE_PATH);
const results = parseWorkbook(buffer); const results = parseWorkbook(buffer);
expect(results).toHaveLength(1); expect(results).toHaveLength(1);

View file

@ -49,14 +49,16 @@ Each PR must include:
**Enforcement** — [`.forgejo/workflows/pr-checks.yml`](../.forgejo/workflows/pr-checks.yml) **Enforcement** — [`.forgejo/workflows/pr-checks.yml`](../.forgejo/workflows/pr-checks.yml)
runs on every PR into `master`: runs on every PR into `master`:
1. **Test-presence gate (hard):** a PR touching `App/app|lib|components|hooks` with no 1. **Test-presence gate:** a PR touching `App/app|lib|components|hooks` with no test
test change fails. Justify genuine exceptions in the PR body for a reviewer to override. change fails. Justify genuine exceptions in the PR body for a reviewer to override.
2. **App-code type-check (hard):** no new `tsc` errors in application code. (The test 2. **Type-check:** `pnpm type-check` must be clean across the whole project (tests
suite has a known pre-existing type-mismatch baseline, tracked separately, so it is included). The test suite's old type baseline was repaired when this gate landed.
filtered out of this gate.) 3. **Unit tests:** `pnpm test` must pass.
3. **Unit tests (advisory):** reported but non-blocking until the unit baseline is green
(2 known failures on `master`). `pnpm lint` is intentionally not run — it currently All three are **hard** gates. `pnpm lint` is intentionally not run — it currently
requires an interactive ESLint migration. requires an interactive ESLint migration (a follow-up). Integration tests are
type-checked here but executed against the `pelagia_test` DB by the autofix / locally
(not in this shared CI, to avoid prod-mirror schema drift).
A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist. A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist.