Audit-trail & transaction consistency (spec §11 "one transition, one row"): - Action types: returnSalary/returnSelection/declineInterviewWaiver no longer mislabel a backward decision as its forward action. New CrewActionType members SALARY_RETURNED / SELECTION_RETURNED / WAIVER_DECLINED; added RECORD_DELETED; dropped the unused GATE_FAILED (migration recreates the enum). - Deletions are audited: deleteDocument / deleteNextOfKin now write a RECORD_DELETED CrewAction (PII removals are traceable). - Atomicity: autoRaiseRequisition takes an optional tx so the leave-clash and sign-off backfills are created INSIDE the approval/sign-off transaction; the office notification (notifyAutoRaised) fires after commit. An approved leave or a sign-off can no longer commit without its backfill requisition. Tests assert the corrected action types (crewing-gates, crew-records) and the existing clash/sign-off suites still pass with the in-transaction backfill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
6 KiB
TypeScript
136 lines
6 KiB
TypeScript
/**
|
|
* Integration tests for the Crewing Phase 4a crew-records actions (documents,
|
|
* bank/EPF, next of kin, PPE, experience). The records tables are new this phase,
|
|
* so afterEach wipes them.
|
|
*/
|
|
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 {
|
|
uploadDocument, deleteDocument, saveBankEpf,
|
|
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience,
|
|
} from "@/app/(portal)/crewing/crew/actions";
|
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
|
import type { Role } from "@prisma/client";
|
|
|
|
let managerId: string;
|
|
let accountsId: string;
|
|
let siteStaffId: string;
|
|
let crewId: string;
|
|
|
|
const SS_EMAIL = "sitestaff@itcrew.local";
|
|
const as = (userId: string, role: Role) =>
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
|
|
|
beforeAll(async () => {
|
|
managerId = (await getSeedUser("manager@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: "ITCREW-SS", email: SS_EMAIL, name: "SS Crew", role: "SITE_STAFF" } });
|
|
siteStaffId = ss.id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.crewAction.deleteMany({});
|
|
await db.seafarerDocument.deleteMany({});
|
|
await db.nextOfKin.deleteMany({});
|
|
await db.ppeIssue.deleteMany({});
|
|
await db.experienceRecord.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 } });
|
|
});
|
|
|
|
async function makeCrew() {
|
|
const c = await db.crewMember.create({ data: { name: "Active Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-T${Date.now() % 100000}` } });
|
|
crewId = c.id;
|
|
return c.id;
|
|
}
|
|
|
|
describe("documents", () => {
|
|
it("uploads and removes a document (with audit)", async () => {
|
|
const id = await makeCrew();
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await uploadDocument(fd({ crewMemberId: id, docType: "PASSPORT", number: "P123", expiryDate: "2030-01-01" })))).toBe(true);
|
|
const doc = await db.seafarerDocument.findFirstOrThrow({ where: { crewMemberId: id } });
|
|
expect(doc.docType).toBe("PASSPORT");
|
|
expect(await db.crewAction.count({ where: { actionType: "DOCUMENT_UPLOADED" } })).toBe(1);
|
|
|
|
expect("ok" in (await deleteDocument(doc.id))).toBe(true);
|
|
expect(await db.seafarerDocument.count({ where: { crewMemberId: id } })).toBe(0);
|
|
// Deletions of PII-bearing records are audited (M3).
|
|
expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1);
|
|
});
|
|
|
|
it("is rejected for a role without upload_crew_records (accounts)", async () => {
|
|
const id = await makeCrew();
|
|
as(accountsId, "ACCOUNTS");
|
|
expect(await uploadDocument(fd({ crewMemberId: id, docType: "PASSPORT" }))).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|
|
|
|
describe("bank & EPF", () => {
|
|
it("upserts bank and EPF details", async () => {
|
|
const id = await makeCrew();
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await saveBankEpf(fd({ crewMemberId: id, accountNumber: "999888777", ifsc: "HDFC0009", uan: "UAN-1" })))).toBe(true);
|
|
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).accountNumber).toBe("999888777");
|
|
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).uan).toBe("UAN-1");
|
|
// Upsert again updates rather than duplicating.
|
|
await saveBankEpf(fd({ crewMemberId: id, accountNumber: "111", ifsc: "X", uan: "UAN-2" }));
|
|
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).accountNumber).toBe("111");
|
|
expect(await db.bankDetail.count({ where: { crewMemberId: id } })).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("next of kin", () => {
|
|
it("adds an emergency contact", async () => {
|
|
const id = await makeCrew();
|
|
as(siteStaffId, "SITE_STAFF"); // site staff can upload crew records
|
|
expect("ok" in (await addNextOfKin(fd({ crewMemberId: id, name: "Spouse", relationship: "Wife", isEmergency: "true" })))).toBe(true);
|
|
const nok = await db.nextOfKin.findFirstOrThrow({ where: { crewMemberId: id } });
|
|
expect(nok.isEmergency).toBe(true);
|
|
// Removal is audited (M3).
|
|
expect("ok" in (await deleteNextOfKin(nok.id))).toBe(true);
|
|
expect(await db.nextOfKin.count({ where: { crewMemberId: id } })).toBe(0);
|
|
expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("PPE", () => {
|
|
it("issues PPE then marks it returned", async () => {
|
|
const id = await makeCrew();
|
|
as(siteStaffId, "SITE_STAFF");
|
|
expect("ok" in (await issuePpe(fd({ crewMemberId: id, item: "SAFETY_SHOES", size: "9", quantity: "1" })))).toBe(true);
|
|
const ppe = await db.ppeIssue.findFirstOrThrow({ where: { crewMemberId: id } });
|
|
expect(ppe.returnedDate).toBeNull();
|
|
expect("ok" in (await returnPpe(ppe.id))).toBe(true);
|
|
expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppe.id } })).returnedDate).not.toBeNull();
|
|
});
|
|
|
|
it("is rejected for a role without issue_ppe (accounts)", async () => {
|
|
const id = await makeCrew();
|
|
as(accountsId, "ACCOUNTS");
|
|
expect(await issuePpe(fd({ crewMemberId: id, item: "HELMET" }))).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|
|
|
|
describe("experience", () => {
|
|
it("adds a declared experience record", async () => {
|
|
const id = await makeCrew();
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await addExperience(fd({ crewMemberId: id, vesselType: "Dredger", durationMonths: "36" })))).toBe(true);
|
|
const e = await db.experienceRecord.findFirstOrThrow({ where: { crewMemberId: id } });
|
|
expect(e.source).toBe("declared");
|
|
expect(e.durationMonths).toBe(36);
|
|
});
|
|
});
|