Final slice of Phase 5. The appraisal lifecycle raise → verify → approve across three role-gated surfaces, per Crewing-Implementation-Spec §5.4/§8.14. Stacks on 5a verification. Behind NEXT_PUBLIC_CREWING_ENABLED. Completes Phase 5. What's in - Schema: Appraisal (on CrewAssignment) + AppraisalStatus (DRAFT/SUBMITTED/MPO_VERIFIED/MANAGER_APPROVED/REJECTED); CrewActionType += APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED. Migration crewing_appraisal. - State machine lib/appraisal-state-machine.ts: verify (SUBMITTED→MPO_VERIFIED, MPO/Manager), approve (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject. - Actions (crewing/appraisals/actions.ts): raiseAppraisal (raise_appraisal — PM/ site staff), verifyAppraisal (verify_appraisal — MPO), approveAppraisal (approve_appraisal — Manager); reject paths require remarks; notifications APPRAISAL_FOR_VERIFICATION / APPRAISAL_FOR_APPROVAL. - Three surfaces (§8.14): PM raises + tracks status on the crew-profile Appraisals tab; MPO verifies in the Verification queue (Appraisals section); Manager approves in the central /approvals queue (Appraisal kind). Tests & docs - Unit: appraisal-state-machine.test.ts (4). Integration: appraisal.test.ts (4) — raise→verify→approve happy path, MPO reject, permission gating (MPO can't raise, site staff can't verify, MPO can't approve). type-check clean; full unit (245) + integration (205) green (verified with RESEND_API_KEY unset). - CLAUDE.md updated — completes Phase 5 (I + H). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
4.6 KiB
TypeScript
108 lines
4.6 KiB
TypeScript
/**
|
|
* Integration tests for Crewing Phase 5b appraisal: the
|
|
* raise (PM) → verify (MPO) → approve (Manager) lifecycle, with rejection paths
|
|
* and role gating per §5.4/§6.
|
|
*/
|
|
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 }));
|
|
vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() }));
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { raiseAppraisal, verifyAppraisal, approveAppraisal } from "@/app/(portal)/crewing/appraisals/actions";
|
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
|
import type { Role } from "@prisma/client";
|
|
|
|
let managerId: string;
|
|
let manningId: string;
|
|
let siteStaffId: string;
|
|
let rankId: string;
|
|
let vesselId: string;
|
|
|
|
const SS_EMAIL = "sitestaff@itapp2.local";
|
|
const as = (userId: string, role: Role) =>
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
|
|
|
async function assignment() {
|
|
const c = await db.crewMember.create({ data: { name: "Appraisee", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
|
const a = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } });
|
|
return { crewId: c.id, assignmentId: a.id };
|
|
}
|
|
|
|
async function raise(assignmentId: string) {
|
|
as(siteStaffId, "SITE_STAFF"); // PM / site staff raise (raise_appraisal)
|
|
const res = await raiseAppraisal(fd({ assignmentId, period: "2026", competence: "4", conduct: "5", safety: "4", comments: "Solid" }));
|
|
if (!("ok" in res)) throw new Error("raise failed");
|
|
return res.id!;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
|
manningId = (await getSeedUser("manning@pelagia.local")).id;
|
|
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITAPP2-SS", email: SS_EMAIL, name: "SS App2", role: "SITE_STAFF" } });
|
|
siteStaffId = ss.id;
|
|
rankId = (await db.rank.findFirstOrThrow()).id;
|
|
vesselId = (await db.vessel.findFirstOrThrow()).id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.crewAction.deleteMany({});
|
|
await db.appraisal.deleteMany({});
|
|
await db.crewAssignment.deleteMany({});
|
|
await db.crewMember.deleteMany({});
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
|
});
|
|
|
|
describe("appraisal lifecycle", () => {
|
|
it("raise → verify (MPO) → approve (Manager)", async () => {
|
|
const { assignmentId } = await assignment();
|
|
const id = await raise(assignmentId);
|
|
expect((await db.appraisal.findUniqueOrThrow({ where: { id } })).status).toBe("SUBMITTED");
|
|
|
|
as(manningId, "MANNING");
|
|
expect("ok" in (await verifyAppraisal(id, true))).toBe(true);
|
|
const verified = await db.appraisal.findUniqueOrThrow({ where: { id } });
|
|
expect(verified.status).toBe("MPO_VERIFIED");
|
|
expect(verified.verifiedById).toBe(manningId);
|
|
|
|
// MPO cannot approve
|
|
expect(await approveAppraisal(id, true)).not.toHaveProperty("ok");
|
|
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await approveAppraisal(id, true))).toBe(true);
|
|
const approved = await db.appraisal.findUniqueOrThrow({ where: { id } });
|
|
expect(approved.status).toBe("MANAGER_APPROVED");
|
|
expect(approved.approvedById).toBe(managerId);
|
|
});
|
|
|
|
it("MPO rejects with remarks", async () => {
|
|
const { assignmentId } = await assignment();
|
|
const id = await raise(assignmentId);
|
|
as(manningId, "MANNING");
|
|
expect("error" in (await verifyAppraisal(id, false))).toBe(true); // remarks required
|
|
expect("ok" in (await verifyAppraisal(id, false, "Incomplete"))).toBe(true);
|
|
const a = await db.appraisal.findUniqueOrThrow({ where: { id } });
|
|
expect(a.status).toBe("REJECTED");
|
|
expect(a.rejectedReason).toBe("Incomplete");
|
|
});
|
|
|
|
it("raise is rejected for a role without raise_appraisal (MPO)", async () => {
|
|
const { assignmentId } = await assignment();
|
|
as(manningId, "MANNING"); // MPO does not hold raise_appraisal
|
|
expect(await raiseAppraisal(fd({ assignmentId, period: "2026" }))).toEqual({ error: "Unauthorized" });
|
|
});
|
|
|
|
it("verify is rejected for a role without verify_appraisal (site staff)", async () => {
|
|
const { assignmentId } = await assignment();
|
|
const id = await raise(assignmentId);
|
|
as(siteStaffId, "SITE_STAFF");
|
|
expect(await verifyAppraisal(id, true)).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|