pelagia-portal/App/tests/integration/crewing-followups.test.ts
Hardik df3b4bdc97
All checks were successful
PR checks / checks (pull_request) Successful in 42s
PR checks / integration (pull_request) Successful in 30s
feat(crewing): resolve self-contained deferred follow-ups (flagged)
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>
2026-06-22 22:28:23 +05:30

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" });
});
});