pelagia-portal/App/tests/integration/requisitions.test.ts
Hardik 93d13a415c feat(crewing): complete requisition list A3 (candidate count + filters)
- A3 AC2: each requisition row shows its candidate count (sourced via
  _count.applications in the list query) alongside the existing days-open age.
- A3 AC1: add rank and reason filters (derived from the visible data, like the
  existing vessel/site filter) on top of search + status + location.

requisitions.test.ts asserts the per-row candidateCount (2 vs 0) the page exposes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:52:05 +05:30

273 lines
12 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";
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<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.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);
});
});