- D1: require a Manager-approved SalaryStructure before onboarding; a SELECTED application with none is now blocked instead of silently binding zero salary rows. - D3 AC2: the CREW_ONBOARDED CrewAction records the created IDs (assignmentId, employeeId, salaryStructureId) in metadata. - Atomicity: the contract letter is uploaded before the transaction and its row is created INSIDE it, so onboarding is one atomic write (no half-onboarded crew member without a contract on failure). onboarding.test.ts asserts the metadata and the new D1 block (no assignment, the candidate stays a CANDIDATE). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
7.4 KiB
TypeScript
152 lines
7.4 KiB
TypeScript
/**
|
|
* Integration tests for the Crewing Phase 3c onboarding action. Onboarding is the
|
|
* side-effecting transaction off a SELECTED application (assignment + employeeId +
|
|
* salary binding + requisition FILLED + crew EMPLOYEE).
|
|
*/
|
|
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 { onboardCandidate } from "@/app/(portal)/crewing/applications/actions";
|
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
|
import type { Role } from "@prisma/client";
|
|
|
|
let managerId: string;
|
|
let siteStaffId: string;
|
|
let rankId: string;
|
|
let vesselId: string;
|
|
|
|
const SS_EMAIL = "sitestaff@itonb.local";
|
|
const as = (userId: string, role: Role) =>
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
|
|
|
let seq = 0;
|
|
async function selectedApplication() {
|
|
seq += 1;
|
|
const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } });
|
|
const cand = await db.crewMember.create({ data: { name: "Selected Sam", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } });
|
|
const app = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } });
|
|
await db.salaryStructure.create({ data: { applicationId: app.id, rateBasis: "MONTHLY", basic: 50000, approvedById: managerId } });
|
|
return { appId: app.id, reqId: req.id, candId: cand.id };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
|
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITONB-SS", email: SS_EMAIL, name: "SS Onb", role: "SITE_STAFF" } });
|
|
siteStaffId = ss.id;
|
|
rankId = (await db.rank.findFirstOrThrow()).id;
|
|
vesselId = (await db.vessel.findFirstOrThrow()).id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.contractLetter.deleteMany({});
|
|
await db.crewAction.deleteMany({});
|
|
await db.salaryStructure.deleteMany({});
|
|
await db.applicationGate.deleteMany({});
|
|
await db.referenceCheck.deleteMany({});
|
|
await db.crewAssignment.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("onboardCandidate", () => {
|
|
it("onboards a SELECTED candidate end-to-end in one transaction", async () => {
|
|
const { appId, reqId, candId } = await selectedApplication();
|
|
as(managerId, "MANAGER");
|
|
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
|
|
expect("ok" in res && res.ok).toBe(true);
|
|
|
|
const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: candId } });
|
|
expect(assignment.status).toBe("ACTIVE");
|
|
expect(assignment.requisitionId).toBe(reqId);
|
|
expect(assignment.rankId).toBe(rankId);
|
|
|
|
const cm = await db.crewMember.findUniqueOrThrow({ where: { id: candId } });
|
|
expect(cm.status).toBe("EMPLOYEE");
|
|
expect(cm.employeeId).toMatch(/^CRW-\d+$/);
|
|
expect(cm.currentRankId).toBe(rankId);
|
|
|
|
expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).stage).toBe("ONBOARDED");
|
|
expect((await db.requisition.findUniqueOrThrow({ where: { id: reqId } })).status).toBe("FILLED");
|
|
|
|
const sal = await db.salaryStructure.findFirstOrThrow({ where: { applicationId: appId } });
|
|
expect(sal.assignmentId).toBe(assignment.id);
|
|
expect(sal.effectiveFrom).not.toBeNull();
|
|
|
|
const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "CREW_ONBOARDED" } });
|
|
expect(action.actorId).toBe(managerId);
|
|
// D3 AC2: the audit row records the created IDs in metadata.
|
|
const meta = action.metadata as { assignmentId?: string; employeeId?: string; salaryStructureId?: string } | null;
|
|
expect(meta?.assignmentId).toBe(assignment.id);
|
|
expect(meta?.employeeId).toBe(cm.employeeId);
|
|
expect(meta?.salaryStructureId).toBe(sal.id);
|
|
});
|
|
|
|
it("blocks onboarding when no salary structure is Manager-approved (D1)", async () => {
|
|
seq += 1;
|
|
const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } });
|
|
const cand = await db.crewMember.create({ data: { name: "Unapproved Sal", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } });
|
|
const appRow = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } });
|
|
// Salary agreed but NOT Manager-approved (approvedById null).
|
|
await db.salaryStructure.create({ data: { applicationId: appRow.id, rateBasis: "MONTHLY", basic: 40000 } });
|
|
|
|
as(managerId, "MANAGER");
|
|
const res = await onboardCandidate(fd({ applicationId: appRow.id, joiningDate: "2026-07-01" }));
|
|
expect("error" in res).toBe(true);
|
|
expect(await db.crewAssignment.count()).toBe(0);
|
|
// The candidate is untouched — still a CANDIDATE, no employee number.
|
|
const after = await db.crewMember.findUniqueOrThrow({ where: { id: cand.id } });
|
|
expect(after.status).toBe("CANDIDATE");
|
|
expect(after.employeeId).toBeNull();
|
|
});
|
|
|
|
it("requires a joining date", async () => {
|
|
const { appId } = await selectedApplication();
|
|
as(managerId, "MANAGER");
|
|
const res = await onboardCandidate(fd({ applicationId: appId }));
|
|
expect("error" in res).toBe(true);
|
|
expect(await db.crewAssignment.count()).toBe(0);
|
|
});
|
|
|
|
it("only onboards from SELECTED", async () => {
|
|
const { appId } = await selectedApplication();
|
|
await db.application.update({ where: { id: appId }, data: { stage: "INTERVIEW" } });
|
|
as(managerId, "MANAGER");
|
|
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
|
|
expect("error" in res).toBe(true);
|
|
expect(await db.crewAssignment.count()).toBe(0);
|
|
});
|
|
|
|
it("is rejected for roles without onboard_crew (site staff, accounts)", async () => {
|
|
const { appId } = await selectedApplication();
|
|
as(siteStaffId, "SITE_STAFF");
|
|
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
|
|
as(managerId, "ACCOUNTS");
|
|
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
|
|
expect(await db.crewAssignment.count()).toBe(0);
|
|
});
|
|
|
|
it("assigns sequential CRW- employee numbers", async () => {
|
|
const a = await selectedApplication();
|
|
const b = await selectedApplication();
|
|
as(managerId, "MANAGER");
|
|
await onboardCandidate(fd({ applicationId: a.appId, joiningDate: "2026-07-01" }));
|
|
await onboardCandidate(fd({ applicationId: b.appId, joiningDate: "2026-07-02" }));
|
|
const ids = (await db.crewMember.findMany({ where: { employeeId: { not: null } }, select: { employeeId: true } })).map((c) => c.employeeId);
|
|
expect(new Set(ids).size).toBe(2);
|
|
expect(ids.every((i) => /^CRW-\d+$/.test(i!))).toBe(true);
|
|
});
|
|
});
|