/** * 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"); }); });