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>
122 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|