/** * Integration tests for the Crewing Phase 3c onboarding action. Onboarding is the * side-effecting transaction off a SELECTED application (assignment + employeeId + * salary binding + requisition FILLED + crew EMPLOYEE). */ 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 { onboardCandidate } from "@/app/(portal)/crewing/applications/actions"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; let managerId: string; let siteStaffId: string; let rankId: string; let vesselId: string; const SS_EMAIL = "sitestaff@itonb.local"; const as = (userId: string, role: Role) => vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); let seq = 0; async function selectedApplication() { seq += 1; const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } }); const cand = await db.crewMember.create({ data: { name: "Selected Sam", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } }); const app = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } }); await db.salaryStructure.create({ data: { applicationId: app.id, rateBasis: "MONTHLY", basic: 50000, approvedById: managerId } }); return { appId: app.id, reqId: req.id, candId: cand.id }; } 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: "ITONB-SS", email: SS_EMAIL, name: "SS Onb", role: "SITE_STAFF" } }); siteStaffId = ss.id; rankId = (await db.rank.findFirstOrThrow()).id; vesselId = (await db.vessel.findFirstOrThrow()).id; }); afterEach(async () => { await db.contractLetter.deleteMany({}); await db.crewAction.deleteMany({}); await db.salaryStructure.deleteMany({}); await db.applicationGate.deleteMany({}); await db.referenceCheck.deleteMany({}); await db.crewAssignment.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("onboardCandidate", () => { it("onboards a SELECTED candidate end-to-end in one transaction", async () => { const { appId, reqId, candId } = await selectedApplication(); as(managerId, "MANAGER"); const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" })); expect("ok" in res && res.ok).toBe(true); const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: candId } }); expect(assignment.status).toBe("ACTIVE"); expect(assignment.requisitionId).toBe(reqId); expect(assignment.rankId).toBe(rankId); const cm = await db.crewMember.findUniqueOrThrow({ where: { id: candId } }); expect(cm.status).toBe("EMPLOYEE"); expect(cm.employeeId).toMatch(/^CRW-\d+$/); expect(cm.currentRankId).toBe(rankId); expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).stage).toBe("ONBOARDED"); expect((await db.requisition.findUniqueOrThrow({ where: { id: reqId } })).status).toBe("FILLED"); const sal = await db.salaryStructure.findFirstOrThrow({ where: { applicationId: appId } }); expect(sal.assignmentId).toBe(assignment.id); expect(sal.effectiveFrom).not.toBeNull(); const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "CREW_ONBOARDED" } }); expect(action.actorId).toBe(managerId); // D3 AC2: the audit row records the created IDs in metadata. const meta = action.metadata as { assignmentId?: string; employeeId?: string; salaryStructureId?: string } | null; expect(meta?.assignmentId).toBe(assignment.id); expect(meta?.employeeId).toBe(cm.employeeId); expect(meta?.salaryStructureId).toBe(sal.id); }); it("blocks onboarding when no salary structure is Manager-approved (D1)", async () => { seq += 1; const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } }); const cand = await db.crewMember.create({ data: { name: "Unapproved Sal", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } }); const appRow = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } }); // Salary agreed but NOT Manager-approved (approvedById null). await db.salaryStructure.create({ data: { applicationId: appRow.id, rateBasis: "MONTHLY", basic: 40000 } }); as(managerId, "MANAGER"); const res = await onboardCandidate(fd({ applicationId: appRow.id, joiningDate: "2026-07-01" })); expect("error" in res).toBe(true); expect(await db.crewAssignment.count()).toBe(0); // The candidate is untouched — still a CANDIDATE, no employee number. const after = await db.crewMember.findUniqueOrThrow({ where: { id: cand.id } }); expect(after.status).toBe("CANDIDATE"); expect(after.employeeId).toBeNull(); }); it("requires a joining date", async () => { const { appId } = await selectedApplication(); as(managerId, "MANAGER"); const res = await onboardCandidate(fd({ applicationId: appId })); expect("error" in res).toBe(true); expect(await db.crewAssignment.count()).toBe(0); }); it("only onboards from SELECTED", async () => { const { appId } = await selectedApplication(); await db.application.update({ where: { id: appId }, data: { stage: "INTERVIEW" } }); as(managerId, "MANAGER"); const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" })); expect("error" in res).toBe(true); expect(await db.crewAssignment.count()).toBe(0); }); it("is rejected for roles without onboard_crew (site staff, accounts)", async () => { const { appId } = await selectedApplication(); as(siteStaffId, "SITE_STAFF"); expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" }); as(managerId, "ACCOUNTS"); expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" }); expect(await db.crewAssignment.count()).toBe(0); }); it("assigns sequential CRW- employee numbers", async () => { const a = await selectedApplication(); const b = await selectedApplication(); as(managerId, "MANAGER"); await onboardCandidate(fd({ applicationId: a.appId, joiningDate: "2026-07-01" })); await onboardCandidate(fd({ applicationId: b.appId, joiningDate: "2026-07-02" })); const ids = (await db.crewMember.findMany({ where: { employeeId: { not: null } }, select: { employeeId: true } })).map((c) => c.employeeId); expect(new Set(ids).size).toBe(2); expect(ids.every((i) => /^CRW-\d+$/.test(i!))).toBe(true); }); });