/** * Integration tests for Crewing Phase 4b leave & attendance: apply/decide leave * (Manager), the clash auto-backfill (required strength = 1), and attendance * recording with MPO/Manager lockout. */ 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 { saveAttendance } from "@/app/(portal)/crewing/attendance/actions"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; let managerId: string; let manningId: string; let siteStaffId: string; let rankId: string; let vesselId: string; const SS_EMAIL = "sitestaff@itla.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) { const cm = await db.crewMember.create({ data: { name, status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); return db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: cm.id, rankId: rId, vesselId } }); } async function applyAndGetId(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"); return res.id!; } 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: "ITLA-SS", email: SS_EMAIL, name: "SS LA", 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.attendance.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("apply / decide leave", () => { it("site staff apply, Manager approves → assignment ON_LEAVE", async () => { const a = await makeAssignment("Solo Crew"); const leaveId = await applyAndGetId(a.id); expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("APPLIED"); as(managerId, "MANAGER"); expect("ok" in (await decideLeave(leaveId, true))).toBe(true); expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("APPROVED"); expect((await db.crewAssignment.findUniqueOrThrow({ where: { id: a.id } })).status).toBe("ON_LEAVE"); }); it("apply is rejected for the MPO (no apply_leave)", async () => { const a = await makeAssignment("X"); as(manningId, "MANNING"); expect(await applyLeave(fd({ assignmentId: a.id, fromDate: "2026-07-01", toDate: "2026-07-02" }))).toEqual({ error: "Unauthorized" }); }); it("decline requires a reason and is Manager-only", async () => { const a = await makeAssignment("Y"); const leaveId = await applyAndGetId(a.id); as(managerId, "MANAGER"); expect("error" in (await decideLeave(leaveId, false, " "))).toBe(true); as(siteStaffId, "SITE_STAFF"); expect(await decideLeave(leaveId, false, "no")).toEqual({ error: "Unauthorized" }); as(managerId, "MANAGER"); expect("ok" in (await decideLeave(leaveId, false, "Operational needs"))).toBe(true); expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("REJECTED"); }); }); describe("clash auto-backfill (required strength = 1)", () => { it("auto-raises a LEAVE requisition when the only same-rank cover goes on leave", async () => { const a = await makeAssignment("Only One"); const leaveId = await applyAndGetId(a.id); as(managerId, "MANAGER"); await decideLeave(leaveId, true); const req = await db.requisition.findFirst({ where: { autoRaised: true } }); expect(req).not.toBeNull(); expect(req!.reason).toBe("LEAVE"); expect(req!.rankId).toBe(rankId); expect(req!.vesselId).toBe(vesselId); }); it("does NOT auto-raise when another active same-rank crew remains (default strength 1)", async () => { const a = await makeAssignment("Going On Leave"); await makeAssignment("Stays Active"); // same rank + vessel, active const leaveId = await applyAndGetId(a.id); as(managerId, "MANAGER"); await decideLeave(leaveId, true); expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0); }); it("auto-raises when a configured required strength exceeds the remaining cover (Option A)", async () => { // Require 2 of this rank on the vessel; with one remaining after leave → clash. await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } }); const a = await makeAssignment("Going On Leave"); await makeAssignment("Stays Active"); const leaveId = await applyAndGetId(a.id); as(managerId, "MANAGER"); await decideLeave(leaveId, true); expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(1); }); }); describe("attendance", () => { it("site staff record attendance (upsert)", async () => { const a = await makeAssignment("Marked"); as(siteStaffId, "SITE_STAFF"); expect("ok" in (await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }, { date: "2026-07-02", status: "ABSENT" }]))).toBe(true); expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(2); // Re-saving the same day updates rather than duplicating. await saveAttendance(a.id, [{ date: "2026-07-01", status: "HALF_DAY" }]); expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(2); expect((await db.attendance.findFirstOrThrow({ where: { assignmentId: a.id, status: "HALF_DAY" } })).status).toBe("HALF_DAY"); }); it("the MPO and the Manager cannot record attendance (R5/§6)", async () => { const a = await makeAssignment("NoMark"); as(manningId, "MANNING"); expect(await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }])).toEqual({ error: "Unauthorized" }); as(managerId, "MANAGER"); expect(await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }])).toEqual({ error: "Unauthorized" }); expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(0); }); });