pelagia-portal/App/tests/integration/verification.test.ts
Hardik e193e26368 feat(crewing): EPFO/UAN assisted verification (GstService pattern, flagged)
Scaffolds EPFO/UAN verification the same way GST works — a standalone Playwright
proxy microservice + an /api proxy + an assisted affordance that records the
result. Aadhaar stays manual (UIDAI-restricted). Stacks on the follow-ups branch.
Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- EpfoService/ (new microservice, GstService pattern): Express + Playwright.
  POST /otp {uan} → session + OTP request; POST /verify {sessionId,uan,otp} →
  member record; GET /health. EPFO is OTP-gated (no anonymous captcha lookup like
  GST), so the handshake is two steps. Live portal navigation is gated behind
  EPFO_LIVE (default STUB: OTP 000000 → matched) until real selectors/OTP are
  validated. README documents the differences + that Aadhaar is out of scope.
- App: /api/epfo/otp + /api/epfo proxies (gated by verify_bank_epf) to
  EPFO_SERVICE_URL. EpfDetail += epfoMemberName + epfoCheckedAt (migration
  crewing_epfo_check). recordEpfoCheck action persists the EPFO result + audit.
- UI: an "EPFO check" affordance on the verification EPF rows — request OTP →
  enter OTP → matched member → record. Aadhaar noted as manual-only.

Tests & docs
- Integration: verification.test.ts gains recordEpfoCheck (records name+timestamp,
  Accounts-only gating). type-check clean; full unit (245) + integration (213)
  green (RESEND_API_KEY unset).
- .env.example (EPFO_SERVICE_URL/EPFO_LIVE), CLAUDE.md, EpfoService/README.

Note: the EpfoService live portal selectors/OTP are stubbed and must be validated
against a real EPFO session before enabling EPFO_LIVE.

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

120 lines
5.3 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, recordEpfoCheck } 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" });
});
});
describe("EPFO assisted check (recordEpfoCheck)", () => {
it("records the EPFO member name + timestamp (Accounts)", async () => {
const { crewId } = await crewWithRecords();
as(accountsId, "ACCOUNTS");
expect("ok" in (await recordEpfoCheck(crewId, "EPFO Member (stub)"))).toBe(true);
const epf = await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } });
expect(epf.epfoMemberName).toBe("EPFO Member (stub)");
expect(epf.epfoCheckedAt).not.toBeNull();
});
it("is rejected for the MPO (no verify_bank_epf)", async () => {
const { crewId } = await crewWithRecords();
as(manningId, "MANNING");
expect(await recordEpfoCheck(crewId, "x")).toEqual({ error: "Unauthorized" });
});
});