/** * Integration tests for Crewing Phase 4c sign-off (Epic K): assignment SIGNED_OFF, * experience record appended, crew member flipped to EX_HAND, and a SIGN_OFF * backfill requisition auto-raised — on the same CrewMember entity. */ 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 { signOffCrew } from "@/app/(portal)/crewing/crew/actions"; import { makeSession, getSeedUser } from "./helpers"; import type { Role } from "@prisma/client"; let managerId: string; let accountsId: string; let siteStaffId: string; let rankId: string; let vesselId: string; const SS_EMAIL = "sitestaff@itso.local"; const as = (userId: string, role: Role) => vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); async function activeCrew() { const c = await db.crewMember.create({ data: { name: "On Tour", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-S${Date.now() % 100000}`, currentRankId: rankId } }); const a = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } }); return { crewId: c.id, assignmentId: a.id }; } beforeAll(async () => { managerId = (await getSeedUser("manager@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: "ITSO-SS", email: SS_EMAIL, name: "SS SO", 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.experienceRecord.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("signOffCrew", () => { it("signs off → SIGNED_OFF + experience record + EX_HAND + backfill requisition", async () => { const { crewId, assignmentId } = await activeCrew(); as(siteStaffId, "SITE_STAFF"); const res = await signOffCrew(assignmentId, "2026-07-01", "End of contract"); expect("ok" in res && res.ok).toBe(true); const a = await db.crewAssignment.findUniqueOrThrow({ where: { id: assignmentId } }); expect(a.status).toBe("SIGNED_OFF"); expect(a.signOffDate).not.toBeNull(); // Same entity flipped back to the candidate pool as an ex-hand. const c = await db.crewMember.findUniqueOrThrow({ where: { id: crewId } }); expect(c.status).toBe("EX_HAND"); expect(c.type).toBe("EX_HAND"); expect(c.employeeId).not.toBeNull(); // history retained const exp = await db.experienceRecord.findFirstOrThrow({ where: { crewMemberId: crewId } }); expect(exp.source).toBe("internal"); expect(exp.rankId).toBe(rankId); expect(exp.durationMonths).toBe(6); // Jan→Jul const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } }); expect(req.reason).toBe("SIGN_OFF"); expect(req.rankId).toBe(rankId); expect(req.vesselId).toBe(vesselId); }); it("refuses to sign off an already signed-off assignment", async () => { const { assignmentId } = await activeCrew(); as(managerId, "MANAGER"); await signOffCrew(assignmentId, "2026-07-01"); const res = await signOffCrew(assignmentId, "2026-08-01"); expect("error" in res).toBe(true); }); it("is rejected for a role without sign_off_crew (accounts)", async () => { const { assignmentId } = await activeCrew(); as(accountsId, "ACCOUNTS"); expect(await signOffCrew(assignmentId, "2026-07-01")).toEqual({ error: "Unauthorized" }); expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0); }); });