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>
103 lines
4.5 KiB
TypeScript
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" });
|
|
});
|
|
});
|