From 53fbdb5c53ae93e9b18214d7ad6bc0ba7a7b52a5 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 22:58:48 +0530 Subject: [PATCH] test(crewing): lock in R2/R8/R11 gates + R6 clash overlap boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two integration suites covering reconciliation rulings that the existing crewing tests left on the happy path only: - leave-clash.test.ts (R6/A5, §5.3): the cover-subtraction and date-overlap paths in leaveCausesClash — a same-rank crew already on an *overlapping* approved leave is not available cover (auto-raises), a non-overlapping leave still counts (no raise), different-rank crew never count, and a configured minStrength still met after the leave does not raise. - crewing-gates.test.ts: salary/selection *returns* are Manager-only and audited (R8); an interview waiver can never reach a NEW candidate by any path, incl. the Manager (R2); bank reject requires remarks; PPE / next-of-kin verify gates are MPO-only with remarks on reject (R11/§8.11); and a SUBMITTED appraisal cannot be Manager-approved without MPO verification (H3). Full suite: 245 unit + 225 integration green. Co-Authored-By: Claude Opus 4.8 --- App/tests/integration/crewing-gates.test.ts | 209 ++++++++++++++++++++ App/tests/integration/leave-clash.test.ts | 149 ++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 App/tests/integration/crewing-gates.test.ts create mode 100644 App/tests/integration/leave-clash.test.ts diff --git a/App/tests/integration/crewing-gates.test.ts b/App/tests/integration/crewing-gates.test.ts new file mode 100644 index 0000000..6e733bd --- /dev/null +++ b/App/tests/integration/crewing-gates.test.ts @@ -0,0 +1,209 @@ +/** + * Integration tests that lock in the Manager-only "return/decline" gates and the + * remaining verification gates across the crewing pipeline — the reconciliation + * rulings most likely to regress silently: + * - R8: salary/selection approval (and their *returns*) are Manager-only. + * - R2: an interview waiver can never reach a NEW candidate by any path. + * - R11/§8.11: PPE / next-of-kin verify gates (MPO) + bank reject-with-remarks. + * - §5.4/H3: only an MPO_VERIFIED appraisal can be Manager-approved. + * Forward happy-paths are already covered by applications/verification/appraisal + * suites; these focus on the negative and role-gating edges. + */ +import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { + returnSalary, + returnSelection, + requestInterviewWaiver, + declineInterviewWaiver, +} from "@/app/(portal)/crewing/applications/actions"; +import { verifyBankEpf, verifyPpe, verifyNextOfKin } from "@/app/(portal)/crewing/verification/actions"; +import { raiseAppraisal, approveAppraisal } from "@/app/(portal)/crewing/appraisals/actions"; +import { makeSession, getSeedUser, fd } from "./helpers"; +import type { ApplicationStage, GateResult, Role } from "@prisma/client"; + +let managerId: string; +let manningId: string; +let accountsId: string; +let siteStaffId: string; +let rankId: string; +let vesselId: string; + +const SS_EMAIL = "sitestaff@itgates.local"; +const as = (userId: string, role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); + +let seq = 0; +async function applicationAt( + stage: ApplicationStage, + opts: { type?: "NEW" | "EX_HAND"; interviewResult?: "PENDING" | "ACCEPTED" } = {} +) { + seq += 1; + const req = await db.requisition.create({ data: { code: `REQ-G${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SHORTLISTING" } }); + const cand = await db.crewMember.create({ + data: { + name: opts.type === "EX_HAND" ? "Ex G" : "New G", + type: opts.type ?? "NEW", + status: opts.type === "EX_HAND" ? "EX_HAND" : "CANDIDATE", + source: opts.type === "EX_HAND" ? "EX_HAND" : "CAREERS", + appliedRankId: rankId, + }, + }); + const app = await db.application.create({ + data: { requisitionId: req.id, crewMemberId: cand.id, stage, type: opts.type ?? "NEW", interviewResult: opts.interviewResult ?? "PENDING" }, + }); + return { appId: app.id, reqId: req.id, candId: cand.id }; +} + +const gate = (applicationId: string, gateType: "SALARY" | "SELECTION" | "WAIVER", result: GateResult = "PENDING") => + db.applicationGate.create({ data: { applicationId, gate: gateType, result } }); + +beforeAll(async () => { + managerId = (await getSeedUser("manager@pelagia.local")).id; + manningId = (await getSeedUser("manning@pelagia.local")).id; + accountsId = (await getSeedUser("accounts@pelagia.local")).id; + const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITGATES-SS", email: SS_EMAIL, name: "SS Gates", role: "SITE_STAFF" } }); + siteStaffId = ss.id; + rankId = (await db.rank.findFirstOrThrow()).id; + vesselId = (await db.vessel.findFirstOrThrow()).id; +}); + +afterEach(async () => { + await db.crewAction.deleteMany({}); + await db.appraisal.deleteMany({}); + await db.salaryStructure.deleteMany({}); + await db.applicationGate.deleteMany({}); + await db.referenceCheck.deleteMany({}); + await db.application.deleteMany({}); + await db.nextOfKin.deleteMany({}); + await db.ppeIssue.deleteMany({}); + await db.bankDetail.deleteMany({}); + await db.epfDetail.deleteMany({}); + await db.crewAssignment.deleteMany({}); + await db.requisition.deleteMany({}); + await db.crewMember.deleteMany({}); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await db.user.deleteMany({ where: { email: SS_EMAIL } }); +}); + +describe("salary return is Manager-only and audited (R8)", () => { + it("MPO cannot return salary; Manager needs a reason; reason rejects the SALARY gate", async () => { + const { appId } = await applicationAt("SALARY_AGREEMENT"); + await db.salaryStructure.create({ data: { applicationId: appId, rateBasis: "MONTHLY", basic: 60000 } }); + await gate(appId, "SALARY"); + + as(manningId, "MANNING"); + expect(await returnSalary(appId, "Too high")).toEqual({ error: "Unauthorized" }); + + as(managerId, "MANAGER"); + expect("error" in (await returnSalary(appId, " "))).toBe(true); // reason required + expect("ok" in (await returnSalary(appId, "Re-negotiate basic"))).toBe(true); + + expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SALARY" } })).result).toBe("REJECTED"); + }); +}); + +describe("selection return is Manager-only (R8)", () => { + it("MPO cannot return a selection; Manager return resets the interview result and rejects the gate", async () => { + const { appId } = await applicationAt("INTERVIEW", { interviewResult: "ACCEPTED" }); + await gate(appId, "SELECTION"); + + as(manningId, "MANNING"); + expect(await returnSelection(appId, "Reconsider")).toEqual({ error: "Unauthorized" }); + + as(managerId, "MANAGER"); + expect("ok" in (await returnSelection(appId, "Pending references"))).toBe(true); + const app = await db.application.findUniqueOrThrow({ where: { id: appId } }); + expect(app.interviewResult).toBe("PENDING"); + expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SELECTION" } })).result).toBe("REJECTED"); + }); +}); + +describe("interview waiver can never reach a NEW candidate (R2)", () => { + it("the Manager cannot request a waiver (no request_interview_waiver) and NEW stays un-waived", async () => { + const { appId } = await applicationAt("INTERVIEW", { type: "NEW" }); + // Manager lacks request_interview_waiver entirely. + as(managerId, "MANAGER"); + expect(await requestInterviewWaiver(appId)).toEqual({ error: "Unauthorized" }); + // MPO can request, but the candidate type blocks it for a NEW hand. + as(manningId, "MANNING"); + expect("error" in (await requestInterviewWaiver(appId))).toBe(true); + expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).interviewWaived).toBe(false); + }); + + it("declining a waiver is Manager-only, needs a reason, and rejects the WAIVER gate", async () => { + const { appId } = await applicationAt("INTERVIEW", { type: "EX_HAND" }); + await gate(appId, "WAIVER"); + + as(manningId, "MANNING"); + expect(await declineInterviewWaiver(appId, "No")).toEqual({ error: "Unauthorized" }); + + as(managerId, "MANAGER"); + expect("error" in (await declineInterviewWaiver(appId, " "))).toBe(true); // reason required + expect("ok" in (await declineInterviewWaiver(appId, "Interview required"))).toBe(true); + expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "WAIVER" } })).result).toBe("REJECTED"); + }); +}); + +describe("bank verification reject path (Accounts, §8.11)", () => { + it("rejecting bank details requires remarks and sets REJECTED", async () => { + const c = await db.crewMember.create({ data: { name: "Bank Reject", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + await db.bankDetail.create({ data: { crewMemberId: c.id, accountNumber: "999", ifsc: "ICIC0001" } }); + + as(accountsId, "ACCOUNTS"); + expect("error" in (await verifyBankEpf(c.id, "bank", false))).toBe(true); // remarks required + expect("ok" in (await verifyBankEpf(c.id, "bank", false, "Name mismatch"))).toBe(true); + expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: c.id } })).verificationStatus).toBe("REJECTED"); + }); +}); + +describe("PPE & next-of-kin verify gates (MPO, §8.11 follow-up)", () => { + it("MPO verifies a next-of-kin record; site staff and Accounts cannot", async () => { + const c = await db.crewMember.create({ data: { name: "NoK Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + const nok = await db.nextOfKin.create({ data: { crewMemberId: c.id, name: "Spouse", relationship: "Wife", isEmergency: true } }); + + as(siteStaffId, "SITE_STAFF"); + expect(await verifyNextOfKin(nok.id, true)).toEqual({ error: "Unauthorized" }); + as(accountsId, "ACCOUNTS"); + expect(await verifyNextOfKin(nok.id, true)).toEqual({ error: "Unauthorized" }); + + as(manningId, "MANNING"); + expect("ok" in (await verifyNextOfKin(nok.id, true))).toBe(true); + expect((await db.nextOfKin.findUniqueOrThrow({ where: { id: nok.id } })).verificationStatus).toBe("VERIFIED"); + }); + + it("MPO rejects a PPE issue only with remarks", async () => { + const c = await db.crewMember.create({ data: { name: "PPE Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + const ppe = await db.ppeIssue.create({ data: { crewMemberId: c.id, item: "BOILER_SUIT", size: "L" } }); + + as(manningId, "MANNING"); + expect("error" in (await verifyPpe(ppe.id, false))).toBe(true); // remarks required + expect("ok" in (await verifyPpe(ppe.id, false, "Wrong size logged"))).toBe(true); + expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppe.id } })).verificationStatus).toBe("REJECTED"); + }); +}); + +describe("appraisal approval requires MPO verification first (H3)", () => { + it("a SUBMITTED appraisal cannot be Manager-approved without MPO verification", async () => { + const c = await db.crewMember.create({ data: { name: "Appraisee G", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + const assignment = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } }); + as(siteStaffId, "SITE_STAFF"); + const raised = await raiseAppraisal(fd({ assignmentId: assignment.id, period: "2026", competence: "4", conduct: "4", safety: "4" })); + if (!("ok" in raised)) throw new Error("raise failed"); + + // Straight to Manager approve, skipping MPO verify → blocked by the state machine. + as(managerId, "MANAGER"); + expect("error" in (await approveAppraisal(raised.id!, true))).toBe(true); + expect((await db.appraisal.findUniqueOrThrow({ where: { id: raised.id! } })).status).toBe("SUBMITTED"); + }); +}); diff --git a/App/tests/integration/leave-clash.test.ts b/App/tests/integration/leave-clash.test.ts new file mode 100644 index 0000000..eb481d0 --- /dev/null +++ b/App/tests/integration/leave-clash.test.ts @@ -0,0 +1,149 @@ +/** + * Integration tests for the Crewing R6 leave-clash detection + * (Crewing-Implementation-Spec §5.3 / Epic A5, Option A). The existing + * leave-attendance suite covers the all-active cases (strength 1 + a configured + * strength 2); these lock in the parts of `leaveCausesClash` that those don't + * exercise — the overlapping-leave cover subtraction and the date-overlap + * predicate — so an approved leave only auto-raises a backfill requisition when + * the *available* same-rank cover over the *window* actually drops below the + * required strength. + */ +import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { applyLeave, decideLeave } from "@/app/(portal)/crewing/leave/actions"; +import { makeSession, getSeedUser, fd } from "./helpers"; +import type { Role } from "@prisma/client"; + +let managerId: string; +let siteStaffId: string; +let rankId: string; +let otherRankId: string; +let vesselId: string; + +const SS_EMAIL = "sitestaff@itclash.local"; +const as = (userId: string, role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); + +async function makeAssignment(name: string, rId = rankId, status: "ACTIVE" | "ON_LEAVE" = "ACTIVE") { + const cm = await db.crewMember.create({ data: { name, status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + return db.crewAssignment.create({ + data: { status, signOnDate: new Date("2026-01-01"), crewMemberId: cm.id, rankId: rId, vesselId }, + }); +} + +// Seed a pre-existing APPROVED leave directly (bypasses the apply/decide flow so +// the window can be controlled precisely without side effects on this run). +async function approvedLeave(assignmentId: string, from: string, to: string) { + return db.leaveRequest.create({ + data: { + assignmentId, + type: "ANNUAL", + fromDate: new Date(from), + toDate: new Date(to), + status: "APPROVED", + appliedById: siteStaffId, + decidedById: managerId, + decidedAt: new Date(), + }, + }); +} + +async function applyAndApprove(assignmentId: string, from = "2026-07-01", to = "2026-07-10") { + as(siteStaffId, "SITE_STAFF"); + const res = await applyLeave(fd({ assignmentId, type: "ANNUAL", fromDate: from, toDate: to })); + if (!("ok" in res)) throw new Error("applyLeave failed"); + as(managerId, "MANAGER"); + await decideLeave(res.id!, true); +} + +const autoRaisedCount = () => db.requisition.count({ where: { autoRaised: true } }); + +beforeAll(async () => { + managerId = (await getSeedUser("manager@pelagia.local")).id; + const ss = await db.user.upsert({ + where: { email: SS_EMAIL }, + update: { role: "SITE_STAFF", isActive: true }, + create: { employeeId: "ITCLASH-SS", email: SS_EMAIL, name: "SS Clash", role: "SITE_STAFF" }, + }); + siteStaffId = ss.id; + const ranks = await db.rank.findMany({ take: 2, orderBy: { name: "asc" } }); + rankId = ranks[0].id; + otherRankId = ranks[1]?.id ?? ranks[0].id; + vesselId = (await db.vessel.findFirstOrThrow()).id; +}); + +afterEach(async () => { + await db.crewAction.deleteMany({}); + await db.leaveRequest.deleteMany({}); + await db.crewAssignment.deleteMany({}); + await db.requisition.deleteMany({}); + await db.vesselRankRequirement.deleteMany({}); + await db.crewMember.deleteMany({}); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await db.user.deleteMany({ where: { email: SS_EMAIL } }); +}); + +describe("clash — overlapping-leave cover subtraction (strength 1)", () => { + it("auto-raises when the only other same-rank crew is already on OVERLAPPING approved leave", async () => { + const a = await makeAssignment("Going On Leave"); + const b = await makeAssignment("Already On Leave"); + // B is already away across A's window → B is not available cover. + await approvedLeave(b.id, "2026-07-05", "2026-07-20"); + + await applyAndApprove(a.id, "2026-07-01", "2026-07-10"); + + expect(await autoRaisedCount()).toBe(1); + const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } }); + expect(req.reason).toBe("LEAVE"); + expect(req.rankId).toBe(rankId); + expect(req.vesselId).toBe(vesselId); + }); + + it("does NOT auto-raise when the other crew's approved leave does NOT overlap the window", async () => { + const a = await makeAssignment("Going On Leave"); + const b = await makeAssignment("Away Later"); + // B's leave is in August — it does not overlap A's July window, so B still + // covers the rank during A's absence. + await approvedLeave(b.id, "2026-08-01", "2026-08-31"); + + await applyAndApprove(a.id, "2026-07-01", "2026-07-10"); + + expect(await autoRaisedCount()).toBe(0); + }); +}); + +describe("clash — rank + strength scoping", () => { + it("ignores cover from a DIFFERENT rank on the same vessel", async () => { + const a = await makeAssignment("Solo In Rank"); + // A different-rank crew member is not cover for A's rank. + await makeAssignment("Other Rank", otherRankId); + + await applyAndApprove(a.id); + + // With no same-rank cover left, the default-strength-1 clash fires + // (unless the two seeded ranks happen to be identical in a thin DB). + expect(await autoRaisedCount()).toBe(rankId === otherRankId ? 0 : 1); + }); + + it("does NOT auto-raise while configured strength is still met after the leave", async () => { + // Require 2; keep 3 active so one going on leave still leaves 2 cover. + await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } }); + const a = await makeAssignment("Going On Leave"); + await makeAssignment("Stays A"); + await makeAssignment("Stays B"); + + await applyAndApprove(a.id); + + expect(await autoRaisedCount()).toBe(0); + }); +});