- 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>
273 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|