pelagia-portal/App/tests/integration/requisitions.test.ts
Hardik 3d30155b6c feat(crewing): Phase 2 — requisitions + relief requests (flagged)
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>
2026-06-22 15:42:07 +05:30

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();
});
});