/** * Integration tests for the Crewing Phase 2 requisition + relief server actions: * raise / cancel / transition, relief request + convert, and the shared * autoRaiseRequisition helper. Mirrors the admin-ranks test setup. * * The Requisition/ReliefRequest/CrewAction tables are introduced in this phase, * so afterEach wipes them wholesale (no pre-existing rows to preserve). */ import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; import React from "react"; // The list page's JSX compiles to classic React.createElement in the node runner. (globalThis as unknown as { React: typeof React }).React = React; vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() })); vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() })); // We read the page element's props directly; the client component is irrelevant. vi.mock("@/app/(portal)/crewing/requisitions/requisitions-manager", () => ({ RequisitionsManager: () => null })); import { auth } from "@/auth"; import { db } from "@/lib/db"; import { raiseRequisition, cancelRequisition, transitionRequisition, requestReliefCover, convertReliefToRequisition, } from "@/app/(portal)/crewing/requisitions/actions"; import RequisitionsPage from "@/app/(portal)/crewing/requisitions/page"; import { autoRaiseRequisition } from "@/lib/requisition-service"; 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@itreq.local"; const as = (userId: string, role: Role) => vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); 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: "ITREQ-SS", email: SS_EMAIL, name: "Site Staff Test", 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.application.deleteMany({}); await db.crewMember.deleteMany({}); await db.reliefRequest.deleteMany({}); await db.requisition.deleteMany({}); vi.clearAllMocks(); }); afterAll(async () => { await db.user.deleteMany({ where: { email: SS_EMAIL } }); }); describe("raiseRequisition", () => { it("creates an OPEN requisition with a REQ- code and an audit action", async () => { as(managerId, "MANAGER"); const res = await raiseRequisition(fd({ rankId, vesselId, reason: "NEW_VACANCY", notes: "Urgent" })); expect("ok" in res && res.ok).toBe(true); const req = await db.requisition.findFirstOrThrow({ include: { actions: true } }); expect(req.status).toBe("OPEN"); expect(req.code).toMatch(/^REQ-\d+$/); expect(req.autoRaised).toBe(false); expect(req.raisedById).toBe(managerId); expect(req.actions).toHaveLength(1); expect(req.actions[0].actionType).toBe("REQUISITION_RAISED"); }); it("requires a vessel or site", async () => { as(managerId, "MANAGER"); const res = await raiseRequisition(fd({ rankId, reason: "NEW_VACANCY" })); expect("error" in res).toBe(true); expect(await db.requisition.count()).toBe(0); }); it("is rejected for a role without raise_requisition (site staff)", async () => { as(siteStaffId, "SITE_STAFF"); const res = await raiseRequisition(fd({ rankId, vesselId })); expect(res).toEqual({ error: "Unauthorized" }); expect(await db.requisition.count()).toBe(0); }); }); describe("cancelRequisition", () => { it("a Manager withdraws an OPEN requisition with a reason", async () => { as(managerId, "MANAGER"); await raiseRequisition(fd({ rankId, vesselId })); const req = await db.requisition.findFirstOrThrow(); const res = await cancelRequisition(req.id, "Vacancy no longer needed"); expect("ok" in res && res.ok).toBe(true); const after = await db.requisition.findUniqueOrThrow({ where: { id: req.id } }); expect(after.status).toBe("CANCELLED"); expect(after.cancellationReason).toBe("Vacancy no longer needed"); expect(after.cancelledAt).not.toBeNull(); }); it("requires a reason", async () => { as(managerId, "MANAGER"); await raiseRequisition(fd({ rankId, vesselId })); const req = await db.requisition.findFirstOrThrow(); const res = await cancelRequisition(req.id, " "); expect("error" in res).toBe(true); }); it("cannot withdraw once past shortlisting", async () => { as(managerId, "MANAGER"); await raiseRequisition(fd({ rankId, vesselId })); const req = await db.requisition.findFirstOrThrow(); await db.requisition.update({ where: { id: req.id }, data: { status: "INTERVIEWING" } }); const res = await cancelRequisition(req.id, "too late"); expect("error" in res).toBe(true); expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("INTERVIEWING"); }); it("the MPO may also withdraw (holds cancel_requisition per ยง6)", async () => { as(managerId, "MANAGER"); await raiseRequisition(fd({ rankId, vesselId })); const req = await db.requisition.findFirstOrThrow(); as(manningId, "MANNING"); const res = await cancelRequisition(req.id, "sourced elsewhere"); expect("ok" in res && res.ok).toBe(true); expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("CANCELLED"); }); it("is rejected for a role without cancel_requisition (site staff)", async () => { as(managerId, "MANAGER"); await raiseRequisition(fd({ rankId, vesselId })); const req = await db.requisition.findFirstOrThrow(); as(siteStaffId, "SITE_STAFF"); const res = await cancelRequisition(req.id, "nope"); expect(res).toEqual({ error: "Unauthorized" }); }); }); describe("transitionRequisition", () => { it("Manager selects from INTERVIEWING; MPO cannot", async () => { as(managerId, "MANAGER"); await raiseRequisition(fd({ rankId, vesselId })); const req = await db.requisition.findFirstOrThrow(); await db.requisition.update({ where: { id: req.id }, data: { status: "INTERVIEWING" } }); as(manningId, "MANNING"); expect(await transitionRequisition(req.id, "mark_selected")).toEqual({ error: "Unauthorized" }); as(managerId, "MANAGER"); const ok = await transitionRequisition(req.id, "mark_selected"); expect("ok" in ok && ok.ok).toBe(true); expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("SELECTED"); }); it("marks FILLED and stamps filledAt", async () => { as(managerId, "MANAGER"); await raiseRequisition(fd({ rankId, vesselId })); const req = await db.requisition.findFirstOrThrow(); await db.requisition.update({ where: { id: req.id }, data: { status: "SELECTED" } }); as(manningId, "MANNING"); const res = await transitionRequisition(req.id, "mark_filled"); expect("ok" in res && res.ok).toBe(true); const after = await db.requisition.findUniqueOrThrow({ where: { id: req.id }, include: { actions: true } }); expect(after.status).toBe("FILLED"); expect(after.filledAt).not.toBeNull(); expect(after.actions.some((a) => a.actionType === "REQUISITION_FILLED")).toBe(true); }); }); describe("relief requests", () => { it("site staff raise an OPEN relief request with an audit action", async () => { as(siteStaffId, "SITE_STAFF"); const res = await requestReliefCover(fd({ rankId, vesselId, note: "Chief going on leave" })); expect("ok" in res && res.ok).toBe(true); const relief = await db.reliefRequest.findFirstOrThrow(); expect(relief.status).toBe("OPEN"); expect(relief.requestedById).toBe(siteStaffId); const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "RELIEF_REQUESTED" } }); expect((action.metadata as { reliefRequestId: string }).reliefRequestId).toBe(relief.id); }); it("is rejected for the MPO (no request_relief_cover)", async () => { as(manningId, "MANNING"); const res = await requestReliefCover(fd({ rankId, vesselId })); expect(res).toEqual({ error: "Unauthorized" }); expect(await db.reliefRequest.count()).toBe(0); }); it("MPO converts a relief request into a requisition and links them", async () => { as(siteStaffId, "SITE_STAFF"); await requestReliefCover(fd({ rankId, vesselId, note: "cover" })); const relief = await db.reliefRequest.findFirstOrThrow(); as(manningId, "MANNING"); const res = await convertReliefToRequisition(fd({ reliefRequestId: relief.id, reason: "REPLACEMENT" })); expect("ok" in res && res.ok).toBe(true); const after = await db.reliefRequest.findUniqueOrThrow({ where: { id: relief.id } }); expect(after.status).toBe("CONVERTED"); expect(after.convertedRequisitionId).not.toBeNull(); const req = await db.requisition.findUniqueOrThrow({ where: { id: after.convertedRequisitionId! }, include: { actions: true, sourceReliefRequest: true }, }); expect(req.status).toBe("OPEN"); expect(req.reason).toBe("REPLACEMENT"); expect(req.sourceReliefRequest?.id).toBe(relief.id); expect(req.actions.some((a) => a.actionType === "RELIEF_CONVERTED")).toBe(true); }); it("refuses to convert an already-handled relief request", async () => { as(siteStaffId, "SITE_STAFF"); await requestReliefCover(fd({ rankId, vesselId })); const relief = await db.reliefRequest.findFirstOrThrow(); as(manningId, "MANNING"); await convertReliefToRequisition(fd({ reliefRequestId: relief.id })); const second = await convertReliefToRequisition(fd({ reliefRequestId: relief.id })); expect("error" in second).toBe(true); }); }); describe("autoRaiseRequisition (shared helper)", () => { it("creates an autoRaised OPEN requisition with no human actor", async () => { const req = await autoRaiseRequisition({ rankId, vesselId, reason: "LEAVE" }); const stored = await db.requisition.findUniqueOrThrow({ where: { id: req.id }, include: { actions: true } }); expect(stored.autoRaised).toBe(true); expect(stored.raisedById).toBeNull(); expect(stored.reason).toBe("LEAVE"); expect(stored.status).toBe("OPEN"); expect(stored.actions[0].actionType).toBe("REQUISITION_RAISED"); expect(stored.actions[0].actorId).toBeNull(); }); }); describe("requisitions list (A3)", () => { it("exposes a candidate count per requisition row", async () => { as(managerId, "MANAGER"); const req = await db.requisition.create({ data: { code: "REQ-A3", rankId, vesselId, reason: "NEW_VACANCY", status: "SHORTLISTING" } }); const empty = await db.requisition.create({ data: { code: "REQ-A3B", rankId, vesselId, reason: "LEAVE", status: "OPEN" } }); for (const name of ["Cand A", "Cand B"]) { const c = await db.crewMember.create({ data: { name, type: "NEW", status: "CANDIDATE", source: "CAREERS" } }); await db.application.create({ data: { requisitionId: req.id, crewMemberId: c.id, stage: "SHORTLISTED", type: "NEW" } }); } const el = (await RequisitionsPage()) as unknown as { props: { requisitions: Array<{ id: string; candidateCount: number }> }; }; expect(el.props.requisitions.find((r) => r.id === req.id)?.candidateCount).toBe(2); expect(el.props.requisitions.find((r) => r.id === empty.id)?.candidateCount).toBe(0); }); });