Second slice of Phase 4 (stacked on 4a crew records). Leave (site-applied, Manager-decided) with clash auto-backfill, and the daily attendance calendar, per Crewing-Implementation-Spec §5.3/§8.9–8.10. Behind NEXT_PUBLIC_CREWING_ENABLED. What's in - Schema (crewing_leave_attendance migration): LeaveRequest (LeaveType, LeaveStatus) + Attendance (AttendanceStatus, unique per assignment+date) on CrewAssignment; CrewActionType += LEAVE_APPLIED/LEAVE_DECIDED/ATTENDANCE_RECORDED. - Leave (R1): site staff apply on behalf (apply_leave); Manager decides (decide_leave) → assignment ON_LEAVE; MPO has no leave role. Leave approvals also surface in the central /approvals queue (§8.13 Leave kind). Notification LEAVE_FOR_APPROVAL. - Clash auto-backfill (R6): lib/leave-clash.ts, required strength = 1 — approving a leave that leaves the vessel with zero active same-rank cover auto-raises a LEAVE requisition via the Phase-2 autoRaiseRequisition. - Attendance (R5): daily month calendar; site staff record (record_attendance), Manager views (view_attendance) but cannot edit, MPO neither. saveAttendance bulk-upserts dirty cells. - Screens: /crewing/leave (apply-on-behalf + Manager Approve/Decline) and /crewing/attendance (tap-to-cycle calendar + Save). Leave + Attendance added to the flag-gated nav (Manager + Site staff). Tests & docs - Integration: leave-attendance.test.ts (7) — apply/decide, clash auto-raise (and no-raise when cover remains), MPO/Manager attendance lockout, permission gating. type-check clean; full unit (240) + integration (182) green. - CLAUDE.md updated with the Phase 4b surface. Deferred: the 6-month leave-planner timeline (lightweight list for now); hours/ overtime attendance (A7). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
6.3 KiB
TypeScript
140 lines
6.3 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.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", 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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|