Merge pull request 'ci: PR policy enforcement (tests + docs) and checks' (#36) from ci/pr-checks into master

Reviewed-on: https://git.pelagiamarine.com/shad0w/pelagia-portal/pulls/36
This commit is contained in:
shad0w 2026-06-19 07:37:39 +00:00
commit 0fe043e833
15 changed files with 229 additions and 111 deletions

View file

@ -0,0 +1,17 @@
<!-- All changes land via PR — no direct pushes to master. -->
## What & why
<!-- Brief summary of the change and the motivation / linked issue (e.g. Closes #NN). -->
## Checklist
- [ ] **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).
- [ ] `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).
<!--
The "PR checks" workflow runs on every PR and hard-fails on: a code change with no
test change, any type-check error, or any failing unit test.
-->

View file

@ -0,0 +1,58 @@
name: PR checks
# Enforces the contribution policy on every PR into master (all gates hard):
# - code changes must ship with tests (docs/config/automation are exempt)
# - type-check is clean across the whole project (tests included)
# - unit tests pass
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
on:
pull_request:
branches: [master]
jobs:
checks:
runs-on: host
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Policy — code changes must include tests
run: |
set -uo pipefail
base="${GITHUB_BASE_REF:-master}"
git fetch origin "$base" --depth=200 -q
changed=$(git diff --name-only "origin/$base...HEAD")
printf 'Changed files:\n%s\n\n' "$changed"
# "Code" = app source (pages, API routes, lib, components, hooks).
# Tests, prisma, config, docs, automation and .forgejo are exempt.
code=$(printf '%s\n' "$changed" | grep -E '^App/(app|lib|components|hooks)/' \
| grep -vE '(\.test\.|\.spec\.|/tests/)' || true)
tests=$(printf '%s\n' "$changed" | grep -E '(\.test\.|\.spec\.|/tests/)' || true)
if [ -n "$code" ] && [ -z "$tests" ]; then
echo "::error::Code changed but no test files changed."
echo "Every code PR must add or update tests (model: the claude/issue-12 integration test)."
echo "If a test is genuinely not applicable, say why in the PR description so a reviewer can override."
printf '\nCode files without accompanying tests:\n%s\n' "$code"
exit 1
fi
echo "OK — test-presence policy satisfied."
- name: Type-check (no errors)
run: |
set -e
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
cd App
pnpm install --frozen-lockfile
pnpm db:generate # prisma client types (no DB connection needed)
pnpm type-check # whole project, tests included — must be clean
- name: Unit tests
run: |
set -e
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
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
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 result = await createPo(form);
return (result as { id: string }).id;
@ -60,7 +60,7 @@ async function createSubmittedPo(title: string): Promise<string> {
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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvePo({ poId });
expect(result).toEqual({ ok: true });
@ -72,7 +72,7 @@ describe("M-02 — approve PO", () => {
it("stores managerNote when approving with note", async () => {
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 });
@ -88,7 +88,7 @@ describe("M-02 — approve PO", () => {
const { notify } = await import("@/lib/notifier");
vi.mocked(notify).mockClear();
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 });
expect(vi.mocked(notify)).toHaveBeenCalledWith(
@ -98,18 +98,18 @@ describe("M-02 — approve PO", () => {
it("returns error when TECHNICAL role tries to approve", async () => {
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 });
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"));
vi.mocked(auth as unknown as () => Promise<unknown>).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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvePo({ poId });
expect(result).toHaveProperty("error");
});
@ -120,7 +120,7 @@ describe("M-02 — approve PO", () => {
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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" });
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 () => {
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" });
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");
vi.mocked(notify).mockClear();
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" });
expect(vi.mocked(notify)).toHaveBeenCalledWith(
@ -157,7 +157,7 @@ describe("M-03 — reject PO", () => {
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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await requestEdits({ poId, note: "Please add vendor ID" });
expect(result).toEqual({ ok: true });
@ -173,7 +173,7 @@ describe("M-04 — request edits", () => {
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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await requestVendorId({ poId });
expect(result).toEqual({ ok: true });
@ -188,10 +188,10 @@ describe("M-04 — request 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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
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 });
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 });
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 });
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 });
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 () => {
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" });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "resubmit" });
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "submit" });
const result = await updatePo(poId, form);
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 () => {
// 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 { 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);
expect(result).toEqual({ id: poId });

View file

@ -54,18 +54,18 @@ afterEach(async () => {
/** Create a PO and drive it to PAID_DELIVERED (fully paid). */
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 { 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 });
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
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;
}
@ -167,7 +167,7 @@ describe("confirmReceipt — permission guards", () => {
const otherTech = await getSeedUser("tech@pelagia.local");
// Use a different user id to simulate a different submitter
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 });
expect(result).toHaveProperty("error");
@ -176,7 +176,7 @@ describe("confirmReceipt — permission guards", () => {
it("rejects confirmation on a PO in wrong status", async () => {
// 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 { id: poId } = (await createPo(form)) as { id: string };
@ -190,7 +190,7 @@ describe("confirmReceipt — permission guards", () => {
});
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" });
expect(result).toHaveProperty("error");
});

View file

@ -43,7 +43,7 @@ afterEach(async () => {
describe("S-02 — save as draft", () => {
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({
title: `${PREFIX}Draft`,
@ -59,7 +59,7 @@ describe("S-02 — save as draft", () => {
});
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 result = await createPo(form);
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 () => {
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 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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = new FormData();
form.set("title", `${PREFIX}NoVessel`);
form.set("accountId", accountId);
@ -93,7 +93,7 @@ describe("S-02 — save as draft", () => {
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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({
title: `${PREFIX}LineItems`,
@ -120,7 +120,7 @@ describe("S-01 — create PO with line items", () => {
});
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
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 () => {
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" });
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 () => {
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 result = await createPo(form);
expect(result).not.toHaveProperty("error");
@ -165,7 +165,7 @@ describe("S-01 — create PO with line items", () => {
describe("S-03 — submit for approval", () => {
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 result = await createPo(form);
@ -180,7 +180,7 @@ describe("S-03 — submit for approval", () => {
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"));
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" });
await createPo(form);

View file

@ -44,7 +44,7 @@ afterEach(async () => {
});
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 result = await createPo(form);
return (result as { id: string }).id;
@ -55,7 +55,7 @@ async function createDraft(title: string, asUserId = techId, asRole: Parameters<
describe("discard — happy path", () => {
it("owner (TECHNICAL) can discard their own DRAFT", async () => {
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);
expect(result).toEqual({ ok: true });
@ -64,7 +64,7 @@ describe("discard — happy path", () => {
it("MANAGER can discard any DRAFT PO (not their own)", async () => {
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);
expect(result).toEqual({ ok: true });
@ -74,7 +74,7 @@ describe("discard — happy path", () => {
it("SUPERUSER can discard any DRAFT PO", async () => {
const superuser = await getSeedUser("admin@pelagia.local");
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);
expect(result).toEqual({ ok: true });
@ -87,7 +87,7 @@ describe("discard — happy path", () => {
const before = await db.pOAction.findMany({ where: { poId } });
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);
const after = await db.pOAction.findMany({ where: { poId } });
@ -99,7 +99,7 @@ describe("discard — happy path", () => {
const linesBefore = await db.pOLineItem.findMany({ where: { poId } });
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);
const linesAfter = await db.pOLineItem.findMany({ where: { poId } });
@ -112,7 +112,7 @@ describe("discard — happy path", () => {
describe("discard — negative / permission tests", () => {
it("returns error for unauthenticated request", async () => {
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");
});
@ -120,7 +120,7 @@ describe("discard — negative / permission tests", () => {
// Create PO as manager, try to discard as tech
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);
expect(result).toHaveProperty("error");
// PO must still exist
@ -129,14 +129,14 @@ describe("discard — negative / permission tests", () => {
it("ACCOUNTS cannot discard any PO (not in allowed roles)", async () => {
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);
expect(result).toHaveProperty("error");
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull();
});
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");
expect(result).toHaveProperty("error");
});
@ -146,11 +146,11 @@ describe("discard — negative / permission tests", () => {
describe("discard — status guard", () => {
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 { 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);
expect(result).toHaveProperty("error");
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
* 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() }));
@ -50,13 +50,13 @@ function makeFileRequest(filePath?: string) {
describe("POST /api/po/import — authorization", () => {
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));
expect(res.status).toBe(401);
});
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));
expect(res.status).toBe(403);
const data = await res.json();
@ -64,13 +64,13 @@ describe("POST /api/po/import — authorization", () => {
});
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));
expect(res.status).toBe(403);
});
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));
expect(res.status).toBe(200);
});
@ -80,7 +80,7 @@ describe("POST /api/po/import — authorization", () => {
describe("POST /api/po/import — input validation", () => {
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 () => {
@ -106,7 +106,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
let results: ParsedImport[];
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 data = await res.json();
results = data.results;
@ -120,9 +120,9 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
const items = results[0].lineItems;
const hasTcText = items.some(
(li) =>
li.description.toLowerCase().includes("please quote") ||
li.description.toLowerCase().includes("delivery :") ||
li.description.toLowerCase().includes("payment terms")
li.name.toLowerCase().includes("please quote") ||
li.name.toLowerCase().includes("delivery :") ||
li.name.toLowerCase().includes("payment terms")
);
expect(hasTcText).toBe(false);
});
@ -132,7 +132,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
});
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)", () => {

View file

@ -11,7 +11,7 @@ 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 { approvePo } from "@/app/(portal)/approvals/[id]/actions";
import { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
import {
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
@ -48,7 +48,7 @@ afterEach(async () => {
describe("MANAGER — create PO", () => {
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 result = await createPo(form);
@ -59,7 +59,7 @@ describe("MANAGER — create PO", () => {
});
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 result = await createPo(form);
@ -70,7 +70,7 @@ describe("MANAGER — create PO", () => {
});
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 { 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 () => {
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 { id: poId } = (await createPo(form)) as { id: string };
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", () => {
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 result = await createPo(form);
expect(result).toHaveProperty("error");
});
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 result = await createPo(form);
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 () => {
// 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({
title: `${PREFIX}SelfApprove`,
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
// because approval authority is role-based, not submitter-based.
// 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
expect(result).toEqual({ ok: true });
});

View file

@ -47,11 +47,11 @@ afterEach(async () => {
// Helper: create PO → submit → approve (reaches MGR_APPROVED)
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 { 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 });
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 () => {
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 });
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 () => {
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 });
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 () => {
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 });
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY });
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 () => {
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 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 () => {
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 });
const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY });
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 () => {
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 });
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 });
@ -137,7 +137,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
const { notify } = await import("@/lib/notifier");
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();
await processPayment({ poId });
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 () => {
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 });
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 });
expect(result).toHaveProperty("error");
});

View file

@ -2,7 +2,7 @@
* Integration tests for GET /api/products/search.
* 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() }));
@ -31,19 +31,19 @@ function makeRequest(query: string) {
describe("GET /api/products/search — authorization", () => {
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"));
expect(res.status).toBe(401);
});
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"));
expect(res.status).toBe(200);
});
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"));
expect(res.status).toBe(200);
});
@ -53,7 +53,7 @@ describe("GET /api/products/search — authorization", () => {
describe("GET /api/products/search — query validation", () => {
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 () => {
@ -79,7 +79,7 @@ describe("GET /api/products/search — query validation", () => {
describe("GET /api/products/search — search behaviour", () => {
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 () => {

View file

@ -16,7 +16,7 @@ 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, 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 {
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
@ -76,7 +76,7 @@ afterEach(async () => {
});
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({
title,
vesselId,
@ -93,9 +93,9 @@ async function makeReviewPo(title: string, withVendor = false) {
describe("approval — vendor required", () => {
it("blocks approval when PO has no vendor assigned", async () => {
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 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 () => {
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 });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
@ -120,14 +120,14 @@ describe("approval — vendor required", () => {
describe("provideVendorId — role expansion", () => {
async function makePendingPo(title: string) {
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 });
return poId;
}
it("ACCOUNTS can provide a verified vendor ID", async () => {
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 });
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 () => {
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 });
expect(result).toHaveProperty("error");
@ -150,7 +150,7 @@ describe("provideVendorId — role expansion", () => {
it("AUDITOR cannot provide vendor ID", async () => {
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 });
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 () => {
// PO still in MGR_REVIEW — no requestVendorId called
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 });
expect(result).toHaveProperty("error");

View file

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

View file

@ -3,13 +3,17 @@
* Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic
* workbooks built in-memory, without any HTTP or database layer.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
import * as XLSX from "xlsx";
import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser";
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 ───────────────────────────────────────────────────────────────────
@ -77,7 +81,7 @@ describe("cellNum", () => {
// ── parseSheet against real Sample_PO.xlsx ───────────────────────────────────
describe("parseSheet — Sample_PO.xlsx", () => {
describe.skipIf(!HAS_SAMPLE)("parseSheet — Sample_PO.xlsx", () => {
let parsed: ReturnType<typeof parseSheet>;
beforeAll(() => {
@ -248,7 +252,7 @@ describe("parseSheet — synthetic edge cases", () => {
// ── 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 results = parseWorkbook(buffer);
expect(results).toHaveLength(1);

View file

@ -32,6 +32,36 @@ Claude in a steered session). The triage breakdown comment is plain (no bot
marker) so, for `claude-queue` issues, the fix stage reads it back as refined
requirements.
## Contribution policy (all changes via PR)
**Every change lands through a pull request — no direct pushes to `master`.** This applies
to humans and to the automated pipeline alike (the watcher already opens PRs).
Each PR must include:
- **Tests** for any code change. Model: the integration test on `claude/issue-12`
it targets the prod-mirror test DB, anchors on existing rows, inserts fixtures via
raw SQL (schema-tolerant), isolates them with a unique prefix, and cleans up in
`afterEach`. Docs/config/automation-only PRs are exempt.
- **Docs** updates where relevant (`App/README.md`, `App/CLAUDE.md`, `Docs/`,
this file, `CHANGELOG.md`).
**Enforcement** — [`.forgejo/workflows/pr-checks.yml`](../.forgejo/workflows/pr-checks.yml)
runs on every PR into `master`:
1. **Test-presence gate:** a PR touching `App/app|lib|components|hooks` with no test
change fails. Justify genuine exceptions in the PR body for a reviewer to override.
2. **Type-check:** `pnpm type-check` must be clean across the whole project (tests
included). The test suite's old type baseline was repaired when this gate landed.
3. **Unit tests:** `pnpm test` must pass.
All three are **hard** gates. `pnpm lint` is intentionally not run — it currently
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.
## Components
| Piece | Where | Notes |

View file

@ -282,13 +282,20 @@ while [ "$f" -lt "$n_fix" ]; do
printf '%s\n' " NEVER use a broad 'pkill -f next' -- it would kill the production app."
printf '%s\n' "- Never connect to or modify the production database or the production app."
printf '%s\n' ""
printf '%s\n' "## Your job"
printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)"
printf '%s\n' "1. Investigate the issue and implement a focused, minimal fix in this repository."
printf '%s\n' "2. Verify: run 'pnpm type-check' and 'pnpm lint' in App/. If behaviour is covered by unit"
printf '%s\n' " tests, run them; for DB-backed behaviour, run integration tests against the test DB above."
printf '%s\n' "3. Add or adjust tests when it makes sense."
printf '%s\n' "4. Commit ALL changes to the current branch with a conventional message ending: Fixes #$num"
printf '%s\n' "5. Do NOT push, do NOT create tags, do NOT switch branches. The supervisor handles push and PR."
printf '%s\n' "2. REQUIRED: add or update a test that fails before your fix and passes after. Model it on"
printf '%s\n' " App/tests/integration/dashboard-approved-this-month.test.ts (from issue #12): target the"
printf '%s\n' " prod-mirror test DB, anchor on existing rows (findFirstOrThrow), insert fixtures via raw"
printf '%s\n' " SQL with a unique prefix, and clean them up in afterEach. The PR check REJECTS code"
printf '%s\n' " changes under App/app|lib|components|hooks with no test change."
printf '%s\n' "3. Verify: 'pnpm type-check' (no new app-code errors) and run your test against the test DB:"
printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration"
printf '%s\n' "4. REQUIRED: update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/,"
printf '%s\n' " CHANGELOG.md) — skip only if nothing documented is affected."
printf '%s\n' "5. Commit ALL changes (fix + test + docs) to the current branch with a conventional message"
printf '%s\n' " ending: Fixes #$num"
printf '%s\n' "6. Do NOT push, do NOT create tags, do NOT switch branches. The supervisor handles push and PR."
printf '%s\n' "If the issue is unclear, too risky (migrations, payments, permissions), or you cannot verify"
printf '%s\n' "the fix, make NO commits and write a short explanation to CLAUDE_RESULT.md in the repo root."
} > "$prompt_file"