Final slice of Phase 4 (the Epic K piece deferred from Phase 2). Ends a tour of duty and returns the crew member to the candidate pool as an ex-hand. Per Crewing-Implementation-Spec §5.3. Behind NEXT_PUBLIC_CREWING_ENABLED. What's in - Schema: CrewActionType += CREW_SIGNED_OFF (migration crewing_signoff). - signOffCrew(assignmentId, date, remarks) (crewing/crew/actions.ts, sign_off_crew): one transaction — assignment → SIGNED_OFF (+ signOffDate); append an internal ExperienceRecord (rank, on/off dates, computed durationMonths); flip the SAME CrewMember EMPLOYEE → EX_HAND (type/source EX_HAND), so they reappear in Candidates as a returning hand; CrewAction CREW_SIGNED_OFF; then auto-raise a SIGN_OFF backfill requisition via autoRaiseRequisition. - Screen: a "Sign off" button on the crew-profile header (sign_off_crew holders — site staff / MPO / Manager); on success redirects to the Crew directory. Tests & docs - Integration: signoff.test.ts (3) — SIGNED_OFF + experience + EX_HAND + SIGN_OFF backfill, already-signed-off guard, permission gating. type-check clean; full unit (241) + integration (195) green. - CLAUDE.md updated — completes Phase 4 (E/F/G + K). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
4.2 KiB
TypeScript
99 lines
4.2 KiB
TypeScript
/**
|
|
* Integration tests for Crewing Phase 4c sign-off (Epic K): assignment SIGNED_OFF,
|
|
* experience record appended, crew member flipped to EX_HAND, and a SIGN_OFF
|
|
* backfill requisition auto-raised — on the same CrewMember entity.
|
|
*/
|
|
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 { signOffCrew } from "@/app/(portal)/crewing/crew/actions";
|
|
import { makeSession, getSeedUser } from "./helpers";
|
|
import type { Role } from "@prisma/client";
|
|
|
|
let managerId: string;
|
|
let accountsId: string;
|
|
let siteStaffId: string;
|
|
let rankId: string;
|
|
let vesselId: string;
|
|
|
|
const SS_EMAIL = "sitestaff@itso.local";
|
|
const as = (userId: string, role: Role) =>
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
|
|
|
async function activeCrew() {
|
|
const c = await db.crewMember.create({ data: { name: "On Tour", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-S${Date.now() % 100000}`, currentRankId: rankId } });
|
|
const a = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } });
|
|
return { crewId: c.id, assignmentId: a.id };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
|
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
|
|
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITSO-SS", email: SS_EMAIL, name: "SS SO", 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.experienceRecord.deleteMany({});
|
|
await db.crewAssignment.deleteMany({});
|
|
await db.requisition.deleteMany({});
|
|
await db.crewMember.deleteMany({});
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
|
});
|
|
|
|
describe("signOffCrew", () => {
|
|
it("signs off → SIGNED_OFF + experience record + EX_HAND + backfill requisition", async () => {
|
|
const { crewId, assignmentId } = await activeCrew();
|
|
as(siteStaffId, "SITE_STAFF");
|
|
const res = await signOffCrew(assignmentId, "2026-07-01", "End of contract");
|
|
expect("ok" in res && res.ok).toBe(true);
|
|
|
|
const a = await db.crewAssignment.findUniqueOrThrow({ where: { id: assignmentId } });
|
|
expect(a.status).toBe("SIGNED_OFF");
|
|
expect(a.signOffDate).not.toBeNull();
|
|
|
|
// Same entity flipped back to the candidate pool as an ex-hand.
|
|
const c = await db.crewMember.findUniqueOrThrow({ where: { id: crewId } });
|
|
expect(c.status).toBe("EX_HAND");
|
|
expect(c.type).toBe("EX_HAND");
|
|
expect(c.employeeId).not.toBeNull(); // history retained
|
|
|
|
const exp = await db.experienceRecord.findFirstOrThrow({ where: { crewMemberId: crewId } });
|
|
expect(exp.source).toBe("internal");
|
|
expect(exp.rankId).toBe(rankId);
|
|
expect(exp.durationMonths).toBe(6); // Jan→Jul
|
|
|
|
const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } });
|
|
expect(req.reason).toBe("SIGN_OFF");
|
|
expect(req.rankId).toBe(rankId);
|
|
expect(req.vesselId).toBe(vesselId);
|
|
});
|
|
|
|
it("refuses to sign off an already signed-off assignment", async () => {
|
|
const { assignmentId } = await activeCrew();
|
|
as(managerId, "MANAGER");
|
|
await signOffCrew(assignmentId, "2026-07-01");
|
|
const res = await signOffCrew(assignmentId, "2026-08-01");
|
|
expect("error" in res).toBe(true);
|
|
});
|
|
|
|
it("is rejected for a role without sign_off_crew (accounts)", async () => {
|
|
const { assignmentId } = await activeCrew();
|
|
as(accountsId, "ACCOUNTS");
|
|
expect(await signOffCrew(assignmentId, "2026-07-01")).toEqual({ error: "Unauthorized" });
|
|
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0);
|
|
});
|
|
});
|