Clears the self-contained deferrals tracked across phases. Stacks on 5b appraisal. Behind NEXT_PUBLIC_CREWING_ENABLED. - SITE_STAFF login on onboard/placement (Epic D follow-up): lib/crew-login.ts maybeCreateSiteStaffLogin creates a passwordless SITE_STAFF User (sharing the CRW- employee no., siteId = the assignment's site) when a grantsLogin rank is onboarded (onboardCandidate) or placed (placeCrew) and the crew member has an email. No-op otherwise. - Own-site scoping (Epic E follow-up, §8.7): User.siteId added (migration crewing_followups); the Crew directory filters a SITE_STAFF user with a home site to crew whose active assignment is at that site (graceful when unset). The link is set at login creation. - PPE / next-of-kin verify gates (Epic F/I follow-up): PpeIssue/NextOfKin gained verificationStatus + verifiedById; verifyPpe / verifyNextOfKin (verify_site_records, MPO) + queue sections in /crewing/verification. Tests & docs - Integration: crewing-followups.test.ts (6) — login created/skipped by rank+email (+ siteId set), PPE/NoK verify + reject-reason + already-decided guard + gating. type-check clean; full unit (245) + integration (211) green (RESEND_API_KEY unset). - CLAUDE.md updated. Part of Epic D (#78), Epic E (#79), Epic F (#80), Epic I (#83). Still deferred (not self-contained): public careers API (A2); Pay-status pay rows (Phase 6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
5.4 KiB
TypeScript
115 lines
5.4 KiB
TypeScript
/**
|
|
* 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<unknown>).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" });
|
|
});
|
|
});
|