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:
commit
0fe043e833
15 changed files with 229 additions and 111 deletions
17
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
17
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal 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.
|
||||
-->
|
||||
58
.forgejo/workflows/pr-checks.yml
Normal file
58
.forgejo/workflows/pr-checks.yml
Normal 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
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue