Adds two integration suites covering reconciliation rulings that the existing crewing tests left on the happy path only: - leave-clash.test.ts (R6/A5, §5.3): the cover-subtraction and date-overlap paths in leaveCausesClash — a same-rank crew already on an *overlapping* approved leave is not available cover (auto-raises), a non-overlapping leave still counts (no raise), different-rank crew never count, and a configured minStrength still met after the leave does not raise. - crewing-gates.test.ts: salary/selection *returns* are Manager-only and audited (R8); an interview waiver can never reach a NEW candidate by any path, incl. the Manager (R2); bank reject requires remarks; PPE / next-of-kin verify gates are MPO-only with remarks on reject (R11/§8.11); and a SUBMITTED appraisal cannot be Manager-approved without MPO verification (H3). Full suite: 245 unit + 225 integration green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
5.9 KiB
TypeScript
149 lines
5.9 KiB
TypeScript
/**
|
|
* 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<unknown>).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);
|
|
});
|
|
});
|