pelagia-portal/App/tests/integration/candidates.test.ts
Hardik be6db075dc feat(crewing): Phase 3a — candidates / talent pool (flagged)
First slice of Phase 3 (Epics B/C/D shipped as stacked sub-PRs). Adds the
CrewMember talent-pool spine and the Candidates screens. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged. Stacks on the requisitions
branch (Phase 2).

What's in
- Schema (crewing_candidates migration): CrewMember (spine) + CrewStatus,
  CandidateType, CandidateSource enums; CrewAction gains a nullable crewMemberId;
  CrewActionType += CANDIDATE_ADDED/UPDATED. employeeId is assigned at onboarding
  (3c), so it's nullable here.
- Actions (crewing/candidates/actions.ts): addCandidate / updateCandidate —
  guard flag + manage_candidates, write a CrewAction, optional CV upload via
  buildStorageKey("cv", …) + uploadBuffer (no parsing — A2 deferred). EX_HAND
  source ⇒ type/status EX_HAND; edits never downgrade an EMPLOYEE.
- Screens: /crewing/candidates (master list with search/source/rank-applied/
  min-experience filters as removable chips + match count + Clear all; Add-candidate
  modal) and /crewing/candidates/[id] (profile; pipeline stepper is 3b). Candidates
  added to the flag-gated Crewing nav (Manager + MPO).

Tests & docs
- Integration: candidates.test.ts (7) — add/update, ex-hand derivation, employee
  no-downgrade, permission gating. type-check clean; full unit (225) + integration
  (153) suites green.
- CLAUDE.md "Crewing" section updated with the Phase 3a surface.

Deferred: public careers intake API (A2, §13 open question); CV parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:23:01 +05:30

122 lines
4.9 KiB
TypeScript

/**
* Integration tests for the Crewing Phase 3a candidate server actions
* (addCandidate / updateCandidate). Mirrors the requisitions test setup.
*
* The CrewMember table is introduced in this phase, so afterEach wipes it (and
* its CrewAction rows) wholesale — no pre-existing rows to preserve.
*/
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 { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let siteStaffId: string;
let rankId: string;
const SS_EMAIL = "sitestaff@itcand.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;
const ss = await db.user.upsert({
where: { email: SS_EMAIL },
update: { role: "SITE_STAFF", isActive: true },
create: { employeeId: "ITCAND-SS", email: SS_EMAIL, name: "Site Staff Cand", role: "SITE_STAFF" },
});
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.crewAction.deleteMany({ where: { crewMemberId: { not: null } } });
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("addCandidate", () => {
it("adds a NEW candidate with an audit action and sensible defaults", async () => {
as(managerId, "MANAGER");
const res = await addCandidate(fd({ name: "Asha Rao", source: "CAREERS", appliedRankId: rankId, experienceMonths: "60" }));
expect("ok" in res && res.ok).toBe(true);
const c = await db.crewMember.findFirstOrThrow({ include: { actions: true } });
expect(c.name).toBe("Asha Rao");
expect(c.type).toBe("NEW");
expect(c.status).toBe("CANDIDATE");
expect(c.appliedRankId).toBe(rankId);
expect(c.experienceMonths).toBe(60);
expect(c.employeeId).toBeNull();
expect(c.actions[0].actionType).toBe("CANDIDATE_ADDED");
expect(c.actions[0].actorId).toBe(managerId);
});
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
const c = await db.crewMember.findFirstOrThrow();
expect(c.type).toBe("EX_HAND");
expect(c.status).toBe("EX_HAND");
});
it("requires a name", async () => {
as(managerId, "MANAGER");
const res = await addCandidate(fd({ name: " ", source: "CAREERS" }));
expect("error" in res).toBe(true);
expect(await db.crewMember.count()).toBe(0);
});
it("is rejected for roles without manage_candidates (site staff, accounts)", async () => {
as(siteStaffId, "SITE_STAFF");
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
as(managerId, "ACCOUNTS");
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
expect(await db.crewMember.count()).toBe(0);
});
});
describe("updateCandidate", () => {
it("edits fields and writes a CANDIDATE_UPDATED action", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Edit Me", source: "CAREERS", experienceMonths: "12" }));
const c = await db.crewMember.findFirstOrThrow();
const res = await updateCandidate(fd({ id: c.id, name: "Edited Name", source: "REFERRAL", experienceMonths: "24" }));
expect("ok" in res && res.ok).toBe(true);
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id }, include: { actions: true } });
expect(after.name).toBe("Edited Name");
expect(after.source).toBe("REFERRAL");
expect(after.experienceMonths).toBe(24);
expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true);
});
it("does not downgrade an onboarded EMPLOYEE back to a candidate", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Hired Hannah", source: "CAREERS" }));
const c = await db.crewMember.findFirstOrThrow();
await db.crewMember.update({ where: { id: c.id }, data: { status: "EMPLOYEE" } });
await updateCandidate(fd({ id: c.id, name: "Hired Hannah", source: "CAREERS" }));
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE");
});
it("rejects an unknown id", async () => {
as(managerId, "MANAGER");
const res = await updateCandidate(fd({ id: "nonexistent", name: "X", source: "CAREERS" }));
expect("error" in res).toBe(true);
});
});