pelagia-portal/App/tests/integration/admin-ranks.test.ts
Hardik d0006a8fc7
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 28s
feat(crewing): foundations — SITE_STAFF role, ranks reference data + admin (flagged)
Phase 1 of the Crewing module per wiki Crewing-Implementation-Spec §12, all dark
behind NEXT_PUBLIC_CREWING_ENABLED (off by default — production unchanged).

- schema: add SITE_STAFF to Role; add Rank (self-referential org hierarchy, like
  Account) + RankDocRequirement, RankCategory & SeafarerDocType enums.
- permissions: full §6 crewing grant matrix (PO_ROLE_PERMISSIONS +
  CREWING_ROLE_PERMISSIONS merged); SITE_STAFF row; MPO has no attendance/leave,
  approvals are Manager-only, manage_ranks is Manager+Admin.
- feature flag: CREWING_ENABLED (opt-in "true").
- nav: flag-gated Crewing section scaffold + "Ranks & documents" under Admin.
- reference data: rank-data.ts + rank-doc-data.ts seeded via shared seed-ranks.ts
  in both dev and prod seeds (19 ranks, 118 doc requirements).
- screen: /admin/ranks — rank hierarchy card + per-rank required-documents card.
- role-label/prefix maps updated for the new role.

Tests: unit (permission matrix + flag), integration (ranks admin CRUD, parent
linking, cycle/children guards, doc-requirement upsert/remove, permission gating).
Docs: CLAUDE.md "Crewing (feature-flagged)" section + env var.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:26:04 +05:30

145 lines
6.3 KiB
TypeScript

/**
* 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<unknown>).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<unknown>).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);
});
});