Second slice of Phase 3 (stacked on 3a candidates). The gated 7-stage recruitment pipeline per Crewing-Implementation-Spec §5.1/§8.4–8.5/§8.13. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_pipeline migration): Application (one per requisition+candidate) + 7-stage ApplicationStage; ApplicationGate (SALARY/SELECTION/WAIVER pending = Manager queue items); ReferenceCheck; effective-dated SalaryStructure (attached to the Application now, bound to the assignment in 3c); minimal BankDetail/EpfDetail captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). CrewAction += applicationId; pipeline CrewActionTypes. - State machine: lib/application-pipeline.ts — sourcing advances MPO/Manager; approve_salary + select are Manager-only; orthogonal canReject; BOARD_STAGES. - Actions: addApplication (first candidate → requisition SHORTLISTING), advanceStage, recordReferenceCheck, verifyDocuments (bank/EPF), agreeSalary→approveSalary/returnSalary, recordInterviewResult, requestInterviewWaiver→approve/decline, selectCandidate (→ requisition SELECTED)/returnSelection, rejectApplication. Waiver never automatic (R2). Notifications SALARY/SELECTION/WAIVER + CANDIDATE_PROPOSED. - Screens: pipeline board per requisition (7 columns + Add candidate); application workhorse (7-step stepper + adaptive per-stage action card); "Open pipeline" on the requisition detail. Central /approvals gains a crewing section (inline Approve/Return) for one unified Manager queue (§8.13 R8). Tests & docs - Unit: application-pipeline.test.ts (9). Integration: applications.test.ts (10) — full happy path, salary/selection/waiver approvals + Manager-only gating, failed interview, reject, site-staff lockout. type-check clean; full unit (234) + integration (163) green. - CLAUDE.md "Crewing" updated with the Phase 3b surface. Deferred: onboarding (Epic D, Phase 3c) — SELECTED → ONBOARDED, CrewAssignment, employeeId, requisition → FILLED, salary bound to the assignment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
9.5 KiB
TypeScript
209 lines
9.5 KiB
TypeScript
/**
|
|
* Integration tests for the Crewing Phase 3b recruitment pipeline actions.
|
|
* The Application/Gate/Salary/Bank/EPF tables are introduced in this phase, so
|
|
* afterEach wipes the crewing lifecycle tables wholesale.
|
|
*/
|
|
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 {
|
|
addApplication,
|
|
advanceStage,
|
|
recordReferenceCheck,
|
|
verifyDocuments,
|
|
agreeSalary,
|
|
approveSalary,
|
|
recordInterviewResult,
|
|
requestInterviewWaiver,
|
|
approveInterviewWaiver,
|
|
selectCandidate,
|
|
rejectApplication,
|
|
} from "@/app/(portal)/crewing/applications/actions";
|
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
|
import type { ApplicationStage, Role } from "@prisma/client";
|
|
|
|
let managerId: string;
|
|
let manningId: string;
|
|
let siteStaffId: string;
|
|
let rankId: string;
|
|
let vesselId: string;
|
|
|
|
const SS_EMAIL = "sitestaff@itapp.local";
|
|
const as = (userId: string, role: Role) =>
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
|
|
|
let seq = 0;
|
|
async function freshRequisition() {
|
|
seq += 1;
|
|
return db.requisition.create({ data: { code: `REQ-T${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "OPEN" } });
|
|
}
|
|
async function freshCandidate(type: "NEW" | "EX_HAND" = "NEW") {
|
|
return db.crewMember.create({ data: { name: type === "EX_HAND" ? "Ex Hand" : "New Cand", type, status: type === "EX_HAND" ? "EX_HAND" : "CANDIDATE", source: type === "EX_HAND" ? "EX_HAND" : "CAREERS", appliedRankId: rankId } });
|
|
}
|
|
async function newApplication(type: "NEW" | "EX_HAND" = "NEW") {
|
|
const [req, cand] = await Promise.all([freshRequisition(), freshCandidate(type)]);
|
|
as(managerId, "MANAGER");
|
|
const res = await addApplication(fd({ requisitionId: req.id, crewMemberId: cand.id }));
|
|
if (!("ok" in res)) throw new Error("addApplication failed");
|
|
return { applicationId: res.id!, requisitionId: req.id, crewMemberId: cand.id };
|
|
}
|
|
const setStage = (id: string, stage: ApplicationStage) => db.application.update({ where: { id }, data: { stage } });
|
|
|
|
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: "ITAPP-SS", email: SS_EMAIL, name: "SS App", 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.salaryStructure.deleteMany({});
|
|
await db.applicationGate.deleteMany({});
|
|
await db.referenceCheck.deleteMany({});
|
|
await db.application.deleteMany({});
|
|
await db.bankDetail.deleteMany({});
|
|
await db.epfDetail.deleteMany({});
|
|
await db.requisition.deleteMany({});
|
|
await db.crewMember.deleteMany({});
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
|
});
|
|
|
|
describe("addApplication", () => {
|
|
it("creates a SHORTLISTED application and moves the requisition into SHORTLISTING", async () => {
|
|
const { applicationId, requisitionId } = await newApplication();
|
|
const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
|
|
expect(app.stage).toBe("SHORTLISTED");
|
|
expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SHORTLISTING");
|
|
});
|
|
|
|
it("rejects a duplicate candidate on the same requisition", async () => {
|
|
const { requisitionId, crewMemberId } = await newApplication();
|
|
as(managerId, "MANAGER");
|
|
const res = await addApplication(fd({ requisitionId, crewMemberId }));
|
|
expect("error" in res).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("happy path to PROPOSED", () => {
|
|
it("walks shortlist → competency → docs(+bank/EPF) → salary → manager approval", async () => {
|
|
const { applicationId, crewMemberId } = await newApplication();
|
|
as(manningId, "MANNING");
|
|
expect("ok" in (await advanceStage(applicationId, "start_competency"))).toBe(true);
|
|
await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" }));
|
|
expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
|
|
expect("ok" in (await verifyDocuments(fd({ applicationId, accountNumber: "123456", ifsc: "HDFC0001", uan: "UAN99" })))).toBe(true);
|
|
|
|
// Bank/EPF captured at the docs gate
|
|
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId } })).accountNumber).toBe("123456");
|
|
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId } })).uan).toBe("UAN99");
|
|
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT");
|
|
|
|
// MPO agrees salary → SALARY gate pending
|
|
await agreeSalary(fd({ applicationId, rateBasis: "MONTHLY", basic: "45000" }));
|
|
const gate = await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SALARY" } });
|
|
expect(gate.result).toBe("PENDING");
|
|
|
|
// MPO cannot approve salary
|
|
as(manningId, "MANNING");
|
|
expect(await approveSalary(applicationId)).toEqual({ error: "Unauthorized" });
|
|
|
|
// Manager approves → PROPOSED, structure approved
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await approveSalary(applicationId))).toBe(true);
|
|
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("PROPOSED");
|
|
expect((await db.salaryStructure.findFirstOrThrow({ where: { applicationId } })).approvedById).toBe(managerId);
|
|
});
|
|
});
|
|
|
|
describe("interview → selection", () => {
|
|
it("MPO records pass → Manager selects → SELECTED + requisition SELECTED", async () => {
|
|
const { applicationId, requisitionId } = await newApplication();
|
|
await setStage(applicationId, "INTERVIEW");
|
|
|
|
as(manningId, "MANNING");
|
|
expect("ok" in (await recordInterviewResult(applicationId, true))).toBe(true);
|
|
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SELECTION" } })).result).toBe("PENDING");
|
|
|
|
// MPO cannot select
|
|
expect(await selectCandidate(applicationId)).toEqual({ error: "Unauthorized" });
|
|
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await selectCandidate(applicationId))).toBe(true);
|
|
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
|
|
expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SELECTED");
|
|
});
|
|
|
|
it("a failed interview rejects the application", async () => {
|
|
const { applicationId } = await newApplication();
|
|
await setStage(applicationId, "INTERVIEW");
|
|
as(manningId, "MANNING");
|
|
await recordInterviewResult(applicationId, false, "Did not meet the bar");
|
|
const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
|
|
expect(app.stage).toBe("REJECTED");
|
|
expect(app.rejectedReason).toBe("Did not meet the bar");
|
|
});
|
|
|
|
it("cannot select before an interview result or waiver", async () => {
|
|
const { applicationId } = await newApplication();
|
|
await setStage(applicationId, "INTERVIEW");
|
|
as(managerId, "MANAGER");
|
|
const res = await selectCandidate(applicationId);
|
|
expect("error" in res).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("interview waiver (ex-hands, R2)", () => {
|
|
it("MPO requests, Manager approves, then selection works without an interview", async () => {
|
|
const { applicationId } = await newApplication("EX_HAND");
|
|
await setStage(applicationId, "INTERVIEW");
|
|
|
|
as(manningId, "MANNING");
|
|
expect("ok" in (await requestInterviewWaiver(applicationId, "20 yrs with us"))).toBe(true);
|
|
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "WAIVER" } })).result).toBe("PENDING");
|
|
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await approveInterviewWaiver(applicationId))).toBe(true);
|
|
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).interviewWaived).toBe(true);
|
|
|
|
expect("ok" in (await selectCandidate(applicationId))).toBe(true);
|
|
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
|
|
});
|
|
|
|
it("is refused for a non-ex-hand candidate", async () => {
|
|
const { applicationId } = await newApplication("NEW");
|
|
await setStage(applicationId, "INTERVIEW");
|
|
as(manningId, "MANNING");
|
|
const res = await requestInterviewWaiver(applicationId);
|
|
expect("error" in res).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("rejection", () => {
|
|
it("MPO rejects from a mid stage", async () => {
|
|
const { applicationId } = await newApplication();
|
|
await setStage(applicationId, "DOC_VERIFICATION");
|
|
as(manningId, "MANNING");
|
|
expect("ok" in (await rejectApplication(applicationId, "Docs not in order"))).toBe(true);
|
|
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("REJECTED");
|
|
});
|
|
|
|
it("site staff cannot drive the pipeline", async () => {
|
|
const { applicationId } = await newApplication();
|
|
as(siteStaffId, "SITE_STAFF");
|
|
expect(await advanceStage(applicationId, "start_competency")).toEqual({ error: "Unauthorized" });
|
|
expect(await rejectApplication(applicationId, "x")).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|