/** * Integration tests for the Ranks & Documents admin server actions (Crewing * Phase 1 foundations). Covers create/update/delete, parent linking, the * cycle/children guards, doc-requirement add/remove, and permission gating. */ import { vi, describe, it, expect, beforeAll, afterEach } from "vitest"; vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); // The actions are gated by the crewing flag; force it on for the test run. vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); import { auth } from "@/auth"; import { db } from "@/lib/db"; import { createRank, updateRank, deleteRank, addRankDocRequirement, removeRankDocRequirement, } from "@/app/(portal)/admin/ranks/actions"; import { makeSession, getSeedUser, fd } from "./helpers"; const PREFIX = "ITRANK_"; let managerId: string; const asManager = () => vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER")); beforeAll(async () => { const mgr = await getSeedUser("manager@pelagia.local"); managerId = mgr.id; }); afterEach(async () => { // Break self-relations before deleting so parent/child FK order never bites. const rows = await db.rank.findMany({ where: { code: { startsWith: PREFIX } }, select: { id: true } }); const ids = rows.map((r) => r.id); if (ids.length) { await db.rankDocRequirement.deleteMany({ where: { rankId: { in: ids } } }); await db.rank.updateMany({ where: { id: { in: ids } }, data: { parentId: null } }); await db.rank.deleteMany({ where: { id: { in: ids } } }); } vi.clearAllMocks(); }); describe("createRank", () => { it("creates a rank with its flags", async () => { asManager(); const res = await createRank( fd({ code: `${PREFIX}PM`, name: "Test PM", category: "OPERATIONAL", grantsLogin: "on" }) ); expect(res).toEqual({ ok: true }); const rank = await db.rank.findUnique({ where: { code: `${PREFIX}PM` } }); expect(rank?.grantsLogin).toBe(true); expect(rank?.isSeafarer).toBe(false); expect(rank?.category).toBe("OPERATIONAL"); }); it("rejects a duplicate code", async () => { asManager(); await createRank(fd({ code: `${PREFIX}DUP`, name: "One", category: "SUPPORT" })); const res = await createRank(fd({ code: `${PREFIX}DUP`, name: "Two", category: "SUPPORT" })); expect("error" in res).toBe(true); }); it("is rejected for roles without manage_ranks", async () => { vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "SITE_STAFF")); const res = await createRank(fd({ code: `${PREFIX}NO`, name: "Nope", category: "OPERATIONAL" })); expect(res).toEqual({ error: "Unauthorized" }); expect(await db.rank.findUnique({ where: { code: `${PREFIX}NO` } })).toBeNull(); }); }); describe("updateRank — parent linking & cycle guard", () => { it("links a child to its parent", async () => { asManager(); await createRank(fd({ code: `${PREFIX}P`, name: "Parent", category: "OPERATIONAL" })); const parent = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}P` } }); await createRank(fd({ code: `${PREFIX}C`, name: "Child", category: "OPERATIONAL" })); const child = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}C` } }); const res = await updateRank( fd({ id: child.id, code: `${PREFIX}C`, name: "Child", category: "OPERATIONAL", parentId: parent.id }) ); expect(res).toEqual({ ok: true }); expect((await db.rank.findUnique({ where: { id: child.id } }))?.parentId).toBe(parent.id); }); it("refuses to make a rank its own ancestor", async () => { asManager(); await createRank(fd({ code: `${PREFIX}A`, name: "A", category: "OPERATIONAL" })); const a = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}A` } }); await createRank(fd({ code: `${PREFIX}B`, name: "B", category: "OPERATIONAL", parentId: a.id })); const b = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}B` } }); // Try to make A report to B (its own descendant) → cycle. const res = await updateRank(fd({ id: a.id, code: `${PREFIX}A`, name: "A", category: "OPERATIONAL", parentId: b.id })); expect("error" in res).toBe(true); }); }); describe("deleteRank", () => { it("blocks deletion when the rank has sub-ranks", async () => { asManager(); await createRank(fd({ code: `${PREFIX}TOP`, name: "Top", category: "OPERATIONAL" })); const top = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}TOP` } }); await createRank(fd({ code: `${PREFIX}SUB`, name: "Sub", category: "OPERATIONAL", parentId: top.id })); const res = await deleteRank(top.id); expect("error" in res).toBe(true); expect(await db.rank.findUnique({ where: { id: top.id } })).not.toBeNull(); }); it("deletes a leaf rank and cascades its doc requirements", async () => { asManager(); await createRank(fd({ code: `${PREFIX}LEAF`, name: "Leaf", category: "OPERATIONAL" })); const leaf = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}LEAF` } }); await addRankDocRequirement(fd({ rankId: leaf.id, docType: "PASSPORT", isMandatory: "on" })); const res = await deleteRank(leaf.id); expect(res).toEqual({ ok: true }); expect(await db.rankDocRequirement.findMany({ where: { rankId: leaf.id } })).toHaveLength(0); }); }); describe("rank document requirements", () => { it("adds (upserting) and removes a requirement", async () => { asManager(); await createRank(fd({ code: `${PREFIX}DOC`, name: "Doc", category: "OPERATIONAL", isSeafarer: "on" })); const rank = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}DOC` } }); await addRankDocRequirement(fd({ rankId: rank.id, docType: "STCW", isMandatory: "on" })); // Upsert: same docType again flips it to conditional rather than duplicating. await addRankDocRequirement(fd({ rankId: rank.id, docType: "STCW", isMandatory: "false" })); const reqs = await db.rankDocRequirement.findMany({ where: { rankId: rank.id } }); expect(reqs).toHaveLength(1); expect(reqs[0].isMandatory).toBe(false); const rm = await removeRankDocRequirement(reqs[0].id); expect(rm).toEqual({ ok: true }); expect(await db.rankDocRequirement.findMany({ where: { rankId: rank.id } })).toHaveLength(0); }); });