pelagia-portal/App/tests/integration/leave-attendance.test.ts
Hardik 040a66488d feat(crewing): clash detection by required strength (Option A)
Replace the implicit "strength = 1" clash rule with a configurable per-vessel,
per-rank requirement (director decision). Adds VesselRankRequirement
{vesselId, rankId, minStrength} (migration crewing_vessel_rank_requirement) and
reworks lib/leave-clash.ts → leaveCausesClash: a leave approval clashes when the
remaining active same-rank cover over the window would fall below minStrength
(default 1 when unconfigured), auto-raising a LEAVE requisition. The requirement
is managed by the office (manage_crew, admin UI in the follow-up).

- Integration: leave-attendance.test.ts gains a configured-strength case
  (minStrength 2, one remaining → clash). Full unit (240) + integration (183) green.
- CLAUDE.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:21 +05:30

152 lines
7 KiB
TypeScript

/**
* 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<unknown>).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);
});
});