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>
145 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|