/** * Integration tests for the Crewing Phase 3b recruitment pipeline actions. * The Application/Gate/Salary/Bank/EPF tables are introduced in this phase, so * afterEach wipes the crewing lifecycle tables wholesale. */ 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 { addApplication, advanceStage, recordReferenceCheck, verifyDocuments, agreeSalary, approveSalary, recordInterviewResult, requestInterviewWaiver, approveInterviewWaiver, selectCandidate, rejectApplication, } from "@/app/(portal)/crewing/applications/actions"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { ApplicationStage, Role } from "@prisma/client"; let managerId: string; let manningId: string; let siteStaffId: string; let rankId: string; let vesselId: string; const SS_EMAIL = "sitestaff@itapp.local"; const as = (userId: string, role: Role) => vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); let seq = 0; async function freshRequisition() { seq += 1; return db.requisition.create({ data: { code: `REQ-T${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "OPEN" } }); } async function freshCandidate(type: "NEW" | "EX_HAND" = "NEW") { return db.crewMember.create({ data: { name: type === "EX_HAND" ? "Ex Hand" : "New Cand", type, status: type === "EX_HAND" ? "EX_HAND" : "CANDIDATE", source: type === "EX_HAND" ? "EX_HAND" : "CAREERS", appliedRankId: rankId } }); } async function newApplication(type: "NEW" | "EX_HAND" = "NEW") { const [req, cand] = await Promise.all([freshRequisition(), freshCandidate(type)]); as(managerId, "MANAGER"); const res = await addApplication(fd({ requisitionId: req.id, crewMemberId: cand.id })); if (!("ok" in res)) throw new Error("addApplication failed"); return { applicationId: res.id!, requisitionId: req.id, crewMemberId: cand.id }; } const setStage = (id: string, stage: ApplicationStage) => db.application.update({ where: { id }, data: { stage } }); beforeAll(async () => { managerId = (await getSeedUser("manager@pelagia.local")).id; manningId = (await getSeedUser("manning@pelagia.local")).id; const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITAPP-SS", email: SS_EMAIL, name: "SS App", 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.salaryStructure.deleteMany({}); await db.applicationGate.deleteMany({}); await db.referenceCheck.deleteMany({}); await db.seafarerDocument.deleteMany({}); await db.application.deleteMany({}); await db.bankDetail.deleteMany({}); await db.epfDetail.deleteMany({}); await db.requisition.deleteMany({}); await db.crewMember.deleteMany({}); vi.clearAllMocks(); }); afterAll(async () => { await db.user.deleteMany({ where: { email: SS_EMAIL } }); }); describe("addApplication", () => { it("creates a SHORTLISTED application and moves the requisition into SHORTLISTING", async () => { const { applicationId, requisitionId } = await newApplication(); const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } }); expect(app.stage).toBe("SHORTLISTED"); expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SHORTLISTING"); }); it("rejects a duplicate candidate on the same requisition", async () => { const { requisitionId, crewMemberId } = await newApplication(); as(managerId, "MANAGER"); const res = await addApplication(fd({ requisitionId, crewMemberId })); expect("error" in res).toBe(true); }); }); describe("happy path to PROPOSED", () => { it("walks shortlist → competency → docs(+bank/EPF) → salary → manager approval", async () => { const { applicationId, crewMemberId } = await newApplication(); as(manningId, "MANNING"); expect("ok" in (await advanceStage(applicationId, "start_competency"))).toBe(true); await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" })); expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true); expect("ok" in (await verifyDocuments(fd({ applicationId, accountNumber: "123456", ifsc: "HDFC0001", uan: "UAN99" })))).toBe(true); // Bank/EPF captured at the docs gate expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId } })).accountNumber).toBe("123456"); expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId } })).uan).toBe("UAN99"); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT"); // MPO agrees salary → SALARY gate pending await agreeSalary(fd({ applicationId, rateBasis: "MONTHLY", basic: "45000" })); const gate = await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SALARY" } }); expect(gate.result).toBe("PENDING"); // MPO cannot approve salary as(manningId, "MANNING"); expect(await approveSalary(applicationId)).toEqual({ error: "Unauthorized" }); // Manager approves → PROPOSED, structure approved as(managerId, "MANAGER"); expect("ok" in (await approveSalary(applicationId))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("PROPOSED"); expect((await db.salaryStructure.findFirstOrThrow({ where: { applicationId } })).approvedById).toBe(managerId); }); }); describe("interview → selection", () => { it("MPO records pass → Manager selects → SELECTED + requisition SELECTED", async () => { const { applicationId, requisitionId } = await newApplication(); await setStage(applicationId, "INTERVIEW"); as(manningId, "MANNING"); expect("ok" in (await recordInterviewResult(applicationId, true))).toBe(true); expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SELECTION" } })).result).toBe("PENDING"); // MPO cannot select expect(await selectCandidate(applicationId)).toEqual({ error: "Unauthorized" }); as(managerId, "MANAGER"); expect("ok" in (await selectCandidate(applicationId))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED"); expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SELECTED"); }); it("a failed interview rejects the application", async () => { const { applicationId } = await newApplication(); await setStage(applicationId, "INTERVIEW"); as(manningId, "MANNING"); await recordInterviewResult(applicationId, false, "Did not meet the bar"); const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } }); expect(app.stage).toBe("REJECTED"); expect(app.rejectedReason).toBe("Did not meet the bar"); }); it("cannot select before an interview result or waiver", async () => { const { applicationId } = await newApplication(); await setStage(applicationId, "INTERVIEW"); as(managerId, "MANAGER"); const res = await selectCandidate(applicationId); expect("error" in res).toBe(true); }); }); describe("interview waiver (ex-hands, R2)", () => { it("MPO requests, Manager approves, then selection works without an interview", async () => { const { applicationId } = await newApplication("EX_HAND"); await setStage(applicationId, "INTERVIEW"); as(manningId, "MANNING"); expect("ok" in (await requestInterviewWaiver(applicationId, "20 yrs with us"))).toBe(true); expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "WAIVER" } })).result).toBe("PENDING"); as(managerId, "MANAGER"); expect("ok" in (await approveInterviewWaiver(applicationId))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).interviewWaived).toBe(true); expect("ok" in (await selectCandidate(applicationId))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED"); }); it("is refused for a non-ex-hand candidate", async () => { const { applicationId } = await newApplication("NEW"); await setStage(applicationId, "INTERVIEW"); as(manningId, "MANNING"); const res = await requestInterviewWaiver(applicationId); expect("error" in res).toBe(true); }); }); describe("vetting gates (C3/C5)", () => { it("blocks completing competency & references until a reference is recorded (C5)", async () => { const { applicationId } = await newApplication(); as(manningId, "MANNING"); await advanceStage(applicationId, "start_competency"); // → COMPETENCY_AND_REFERENCES // No reference recorded yet → cannot advance. expect("error" in (await advanceStage(applicationId, "verify_competency"))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("COMPETENCY_AND_REFERENCES"); // Record one → now it advances. await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" })); expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION"); }); it("blocks document verification when a required document on file is expired (C3)", async () => { const { applicationId, requisitionId, crewMemberId } = await newApplication(); await setStage(applicationId, "DOC_VERIFICATION"); const reqRank = (await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).rankId; await db.rankDocRequirement.upsert({ where: { rankId_docType: { rankId: reqRank, docType: "MEDICAL_FITNESS" } }, update: { isMandatory: true }, create: { rankId: reqRank, docType: "MEDICAL_FITNESS", isMandatory: true }, }); await db.seafarerDocument.create({ data: { crewMemberId, docType: "MEDICAL_FITNESS", expiryDate: new Date("2020-01-01") } }); as(manningId, "MANNING"); expect("error" in (await verifyDocuments(fd({ applicationId })))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION"); // Renew the document → advancement proceeds. await db.seafarerDocument.updateMany({ where: { crewMemberId }, data: { expiryDate: new Date("2030-01-01") } }); expect("ok" in (await verifyDocuments(fd({ applicationId })))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT"); }); }); describe("rejection", () => { it("MPO rejects from a mid stage", async () => { const { applicationId } = await newApplication(); await setStage(applicationId, "DOC_VERIFICATION"); as(manningId, "MANNING"); expect("ok" in (await rejectApplication(applicationId, "Docs not in order"))).toBe(true); expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("REJECTED"); }); it("site staff cannot drive the pipeline", async () => { const { applicationId } = await newApplication(); as(siteStaffId, "SITE_STAFF"); expect(await advanceStage(applicationId, "start_competency")).toEqual({ error: "Unauthorized" }); expect(await rejectApplication(applicationId, "x")).toEqual({ error: "Unauthorized" }); }); });