pelagia-portal/App/tests/integration/verification.test.ts
Hardik 8982118eee feat(crewing): Phase 5a — verification queue (flagged)
First slice of Phase 5 (verification + appraisal). The office queue for verifying
site-entered records, per Crewing-Implementation-Spec §8.11/R11. Stacks on 4c.
Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- Schema: CrewActionType += RECORD_VERIFIED/RECORD_REJECTED (migration
  crewing_verification_actions). No model changes — SeafarerDocument/BankDetail/
  EpfDetail already carry verificationStatus + verifiedById (3b/4a).
- Actions (crewing/verification/actions.ts): verifyDocument (verify_site_records —
  MPO/Manager) and verifyBankEpf (verify_bank_epf — Accounts) set
  verificationStatus VERIFIED/REJECTED + verifiedById; rejection requires remarks;
  each writes a CrewAction. Already-decided records are guarded.
- Screen: /crewing/verification — role-aware (MPO: pending documents with expiry
  flags; Accounts: pending bank/EPF), Verify / Reject-with-remarks. Leave is not
  here (Manager approval, R11). Verification added to nav (MPO + Accounts + SU, §7).

Tests & docs
- Integration: verification.test.ts (6) — doc verify/reject + already-decided
  guard, bank/EPF verify, permission gating (Accounts can't verify docs, MPO can't
  verify bank/EPF). type-check clean; full unit (241) + integration (201) green
  (verified with RESEND_API_KEY unset, mimicking CI).
- CLAUDE.md updated.

Deferred (per decision): PPE / next-of-kin verification gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:59:31 +05:30

103 lines
4.5 KiB
TypeScript

/**
* Integration tests for Crewing Phase 5a verification: documents (MPO) and
* bank/EPF (Accounts), with role gating per §6/§8.11.
*/
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 }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { verifyDocument, verifyBankEpf } from "@/app/(portal)/crewing/verification/actions";
import { makeSession, getSeedUser } from "./helpers";
import type { Role } from "@prisma/client";
let manningId: string;
let accountsId: string;
let siteStaffId: string;
const SS_EMAIL = "sitestaff@itver.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
async function crewWithRecords() {
const c = await db.crewMember.create({ data: { name: "To Verify", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
const doc = await db.seafarerDocument.create({ data: { crewMemberId: c.id, docType: "PASSPORT", number: "P999" } });
await db.bankDetail.create({ data: { crewMemberId: c.id, accountNumber: "123456789", ifsc: "HDFC0001" } });
await db.epfDetail.create({ data: { crewMemberId: c.id, uan: "UAN-1" } });
return { crewId: c.id, docId: doc.id };
}
beforeAll(async () => {
manningId = (await getSeedUser("manning@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: "ITVER-SS", email: SS_EMAIL, name: "SS Ver", role: "SITE_STAFF" } });
siteStaffId = ss.id;
});
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.seafarerDocument.deleteMany({});
await db.bankDetail.deleteMany({});
await db.epfDetail.deleteMany({});
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("document verification (MPO)", () => {
it("verifies a document with an audit row", async () => {
const { crewId, docId } = await crewWithRecords();
as(manningId, "MANNING");
expect("ok" in (await verifyDocument(docId, true))).toBe(true);
const d = await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } });
expect(d.verificationStatus).toBe("VERIFIED");
expect(d.verifiedById).toBe(manningId);
expect(await db.crewAction.count({ where: { crewMemberId: crewId, actionType: "RECORD_VERIFIED" } })).toBe(1);
});
it("rejection requires a reason and records it", async () => {
const { docId } = await crewWithRecords();
as(manningId, "MANNING");
expect("error" in (await verifyDocument(docId, false))).toBe(true);
expect("ok" in (await verifyDocument(docId, false, "Illegible scan"))).toBe(true);
expect((await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } })).verificationStatus).toBe("REJECTED");
});
it("won't re-verify an already-decided document", async () => {
const { docId } = await crewWithRecords();
as(manningId, "MANNING");
await verifyDocument(docId, true);
expect("error" in (await verifyDocument(docId, true))).toBe(true);
});
it("is rejected for roles without verify_site_records (accounts, site staff)", async () => {
const { docId } = await crewWithRecords();
as(accountsId, "ACCOUNTS");
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
as(siteStaffId, "SITE_STAFF");
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
});
});
describe("bank/EPF verification (Accounts)", () => {
it("Accounts verifies bank and EPF", async () => {
const { crewId } = await crewWithRecords();
as(accountsId, "ACCOUNTS");
expect("ok" in (await verifyBankEpf(crewId, "bank", true))).toBe(true);
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
expect("ok" in (await verifyBankEpf(crewId, "epf", true))).toBe(true);
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
});
it("is rejected for the MPO (no verify_bank_epf)", async () => {
const { crewId } = await crewWithRecords();
as(manningId, "MANNING");
expect(await verifyBankEpf(crewId, "bank", true)).toEqual({ error: "Unauthorized" });
});
});