/** * Integration tests for the self-contained crewing follow-ups: * - SITE_STAFF login creation on placement/onboarding (grantsLogin ranks) * - PPE / next-of-kin verification gates * (Own-site scoping is exercised via the siteId set on the created login.) */ 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 { placeCrew } from "@/app/(portal)/admin/crew/actions"; import { verifyPpe, verifyNextOfKin } from "@/app/(portal)/crewing/verification/actions"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; let managerId: string; let manningId: string; let accountsId: string; let loginRankId: string; let plainRankId: string; let siteId: string; const as = (userId: string, role: Role) => vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); const LOGIN_EMAIL = "pmlogin.itfu@example.local"; beforeAll(async () => { managerId = (await getSeedUser("manager@pelagia.local")).id; manningId = (await getSeedUser("manning@pelagia.local")).id; accountsId = (await getSeedUser("accounts@pelagia.local")).id; loginRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: true } })).id; plainRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: false } })).id; siteId = (await db.site.findFirstOrThrow()).id; }); afterEach(async () => { await db.crewAction.deleteMany({}); await db.ppeIssue.deleteMany({}); await db.nextOfKin.deleteMany({}); await db.crewAssignment.deleteMany({}); await db.crewMember.deleteMany({}); await db.user.deleteMany({ where: { email: LOGIN_EMAIL } }); vi.clearAllMocks(); }); afterAll(async () => { await db.user.deleteMany({ where: { email: LOGIN_EMAIL } }); }); describe("SITE_STAFF login on placement (grantsLogin ranks)", () => { it("creates a SITE_STAFF login (with home site) for a management-rank placement", async () => { const c = await db.crewMember.create({ data: { name: "New PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } }); as(managerId, "MANAGER"); expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true); const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id } }); const login = await db.user.findUniqueOrThrow({ where: { email: LOGIN_EMAIL } }); expect(login.role).toBe("SITE_STAFF"); expect(login.employeeId).toBe(after.employeeId); // shares the CRW- number expect(login.passwordHash).toBeNull(); expect(login.siteId).toBe(siteId); // own-site link set at creation }); it("creates no login for a non-login rank", async () => { const c = await db.crewMember.create({ data: { name: "Deck Hand", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } }); as(managerId, "MANAGER"); await placeCrew(fd({ crewMemberId: c.id, rankId: plainRankId, siteId, signOnDate: "2026-07-01" })); expect(await db.user.findUnique({ where: { email: LOGIN_EMAIL } })).toBeNull(); }); it("skips the login when the crew member has no email (placement still succeeds)", async () => { const c = await db.crewMember.create({ data: { name: "No Email PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN" } }); as(managerId, "MANAGER"); expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true); expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE"); }); }); describe("PPE / next-of-kin verification (MPO)", () => { async function crewWithRecords() { const c = await db.crewMember.create({ data: { name: "Verify Me", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); const ppe = await db.ppeIssue.create({ data: { crewMemberId: c.id, item: "HELMET" } }); const nok = await db.nextOfKin.create({ data: { crewMemberId: c.id, name: "Spouse" } }); return { ppeId: ppe.id, nokId: nok.id }; } it("MPO verifies PPE and next-of-kin", async () => { const { ppeId, nokId } = await crewWithRecords(); as(manningId, "MANNING"); expect("ok" in (await verifyPpe(ppeId, true))).toBe(true); expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppeId } })).verificationStatus).toBe("VERIFIED"); expect("ok" in (await verifyNextOfKin(nokId, true))).toBe(true); expect((await db.nextOfKin.findUniqueOrThrow({ where: { id: nokId } })).verificationStatus).toBe("VERIFIED"); }); it("rejection requires a reason; already-decided is guarded", async () => { const { ppeId } = await crewWithRecords(); as(manningId, "MANNING"); expect("error" in (await verifyPpe(ppeId, false))).toBe(true); expect("ok" in (await verifyPpe(ppeId, false, "Wrong size"))).toBe(true); expect("error" in (await verifyPpe(ppeId, true))).toBe(true); // already rejected }); it("is rejected for roles without verify_site_records (accounts)", async () => { const { ppeId } = await crewWithRecords(); as(accountsId, "ACCOUNTS"); expect(await verifyPpe(ppeId, true)).toEqual({ error: "Unauthorized" }); }); });