/** * Integration tests for the Crewing R6 leave-clash detection * (Crewing-Implementation-Spec §5.3 / Epic A5, Option A). The existing * leave-attendance suite covers the all-active cases (strength 1 + a configured * strength 2); these lock in the parts of `leaveCausesClash` that those don't * exercise — the overlapping-leave cover subtraction and the date-overlap * predicate — so an approved leave only auto-raises a backfill requisition when * the *available* same-rank cover over the *window* actually drops below the * required strength. */ 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 { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; let managerId: string; let siteStaffId: string; let rankId: string; let otherRankId: string; let vesselId: string; const SS_EMAIL = "sitestaff@itclash.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, status: "ACTIVE" | "ON_LEAVE" = "ACTIVE") { const cm = await db.crewMember.create({ data: { name, status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); return db.crewAssignment.create({ data: { status, signOnDate: new Date("2026-01-01"), crewMemberId: cm.id, rankId: rId, vesselId }, }); } // Seed a pre-existing APPROVED leave directly (bypasses the apply/decide flow so // the window can be controlled precisely without side effects on this run). async function approvedLeave(assignmentId: string, from: string, to: string) { return db.leaveRequest.create({ data: { assignmentId, type: "ANNUAL", fromDate: new Date(from), toDate: new Date(to), status: "APPROVED", appliedById: siteStaffId, decidedById: managerId, decidedAt: new Date(), }, }); } async function applyAndApprove(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"); as(managerId, "MANAGER"); await decideLeave(res.id!, true); } const autoRaisedCount = () => db.requisition.count({ where: { autoRaised: true } }); 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: "ITCLASH-SS", email: SS_EMAIL, name: "SS Clash", role: "SITE_STAFF" }, }); siteStaffId = ss.id; const ranks = await db.rank.findMany({ take: 2, orderBy: { name: "asc" } }); rankId = ranks[0].id; otherRankId = ranks[1]?.id ?? ranks[0].id; vesselId = (await db.vessel.findFirstOrThrow()).id; }); afterEach(async () => { await db.crewAction.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("clash — overlapping-leave cover subtraction (strength 1)", () => { it("auto-raises when the only other same-rank crew is already on OVERLAPPING approved leave", async () => { const a = await makeAssignment("Going On Leave"); const b = await makeAssignment("Already On Leave"); // B is already away across A's window → B is not available cover. await approvedLeave(b.id, "2026-07-05", "2026-07-20"); await applyAndApprove(a.id, "2026-07-01", "2026-07-10"); expect(await autoRaisedCount()).toBe(1); const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } }); expect(req.reason).toBe("LEAVE"); expect(req.rankId).toBe(rankId); expect(req.vesselId).toBe(vesselId); }); it("does NOT auto-raise when the other crew's approved leave does NOT overlap the window", async () => { const a = await makeAssignment("Going On Leave"); const b = await makeAssignment("Away Later"); // B's leave is in August — it does not overlap A's July window, so B still // covers the rank during A's absence. await approvedLeave(b.id, "2026-08-01", "2026-08-31"); await applyAndApprove(a.id, "2026-07-01", "2026-07-10"); expect(await autoRaisedCount()).toBe(0); }); }); describe("clash — rank + strength scoping", () => { it("ignores cover from a DIFFERENT rank on the same vessel", async () => { const a = await makeAssignment("Solo In Rank"); // A different-rank crew member is not cover for A's rank. await makeAssignment("Other Rank", otherRankId); await applyAndApprove(a.id); // With no same-rank cover left, the default-strength-1 clash fires // (unless the two seeded ranks happen to be identical in a thin DB). expect(await autoRaisedCount()).toBe(rankId === otherRankId ? 0 : 1); }); it("does NOT auto-raise while configured strength is still met after the leave", async () => { // Require 2; keep 3 active so one going on leave still leaves 2 cover. await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } }); const a = await makeAssignment("Going On Leave"); await makeAssignment("Stays A"); await makeAssignment("Stays B"); await applyAndApprove(a.id); expect(await autoRaisedCount()).toBe(0); }); });