Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12 (build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED; production is unchanged. Schema is added incrementally — this lands the requisition lifecycle layer. What's in - Schema: Requisition (OPEN→SHORTLISTING→PROPOSING→INTERVIEWING→SELECTED→FILLED, →CANCELLED), ReliefRequest, CrewAction (the POAction mirror) + their enums. Migration crewing_requisitions. - State machine: lib/requisition-state-machine.ts mirrors po-state-machine (selection Manager-only; orthogonal cancel from OPEN/SHORTLISTING by cancel_requisition holders, §6). Codes REQ-9000… via lib/requisition-number.ts. - Actions: raise/cancel/transition + requestReliefCover/convertReliefToRequisition, each guarding flag+permission+state, writing a CrewAction and notifying. Shared autoRaiseRequisition() (lib/requisition-service.ts) is the backfill entry point for sign-off / leave-clash (later phases). - Notifier: notifyCrew() PO-independent path + CrewNotificationEvent. - Screens: /crewing/requisitions (list + Raise modal + relief convert) and /crewing/requisitions/[id] (detail). Requisitions added to the flag-gated Crewing sidebar (Manager + MPO, §7). Tests & docs - Unit: requisition-state-machine.test.ts (11). - Integration: requisitions.test.ts (15) — raise/cancel/transition, relief request + convert, auto-raise, permission gating. - CLAUDE.md "Crewing" section updated with the Phase 2 surface. Deferred: sign-off/experience (Epic K, §12 item 2) depends on the crew/assignment models from Phase 3/4; autoRaiseRequisition() is ready for it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
/**
|
|
* 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";
|
|
|
|
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 {
|
|
raiseRequisition,
|
|
cancelRequisition,
|
|
transitionRequisition,
|
|
requestReliefCover,
|
|
convertReliefToRequisition,
|
|
} from "@/app/(portal)/crewing/requisitions/actions";
|
|
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<unknown>).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.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();
|
|
});
|
|
});
|