Rank held applies to every candidate, not just ex-hands; it auto-updates for returning crew on sign-off. Ex-hand designation is decoupled from the Source dropdown and owned by the office: - Candidate form: drop the EX_HAND source option, relabel "Rank held (ex-hands)" to "Rank held". addCandidate always intakes NEW/CANDIDATE (ex-hand recognition still reuses an existing EX_HAND row); updateCandidate no longer rewrites type/status, so an admin-set EX_HAND or onboarded EMPLOYEE is never clobbered by a candidate edit. - Admin crew form: the type NEW/EX_HAND select becomes an "Ex-hand (returning crew)" checkbox -- the only place ex-hand is tagged. - List/detail ex-hand indicators key on type === EX_HAND (not source). - Sign-off preserves the original recruitment source when flipping to EX_HAND. - Tests seed EX_HAND rows directly; assert candidate intake stays NEW. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
8.2 KiB
TypeScript
188 lines
8.2 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";
|
|
import React from "react";
|
|
// The list page's JSX compiles to classic React.createElement in the node runner.
|
|
(globalThis as unknown as { React: typeof React }).React = React;
|
|
|
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() }));
|
|
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
|
// We read the page element's props directly; the client component is irrelevant.
|
|
vi.mock("@/app/(portal)/crewing/candidates/candidates-manager", () => ({ CandidatesManager: () => null }));
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
|
|
import CandidatesPage from "@/app/(portal)/crewing/candidates/page";
|
|
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));
|
|
|
|
// Ex-hand is an office/admin designation (set on the admin crew record, not the
|
|
// candidate form) — seed such rows directly for the recognition tests.
|
|
const seedExHand = (data: { name: string; email?: string; experienceMonths?: number }) =>
|
|
db.crewMember.create({
|
|
data: {
|
|
name: data.name,
|
|
type: "EX_HAND",
|
|
status: "EX_HAND",
|
|
source: "CAREERS",
|
|
email: data.email ?? null,
|
|
experienceMonths: data.experienceMonths ?? 0,
|
|
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: managerId } },
|
|
},
|
|
});
|
|
|
|
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("candidate intake always creates a NEW candidate — ex-hand is admin-only", async () => {
|
|
as(managerId, "MANAGER");
|
|
// Even if an ex-hand hint is smuggled into the form data, intake stays
|
|
// NEW/CANDIDATE; ex-hand is set only on the admin crew record.
|
|
await addCandidate(fd({ name: "Returning Ravi", source: "CAREERS", isExHand: "true" }));
|
|
const c = await db.crewMember.findFirstOrThrow();
|
|
expect(c.type).toBe("NEW");
|
|
expect(c.status).toBe("CANDIDATE");
|
|
});
|
|
|
|
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("ex-hand recognition + ordering (B3)", () => {
|
|
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
|
|
as(managerId, "MANAGER");
|
|
const exhand = await seedExHand({ name: "Ravi Old", email: "ravi@ex.com", experienceMonths: 120 });
|
|
|
|
// Re-applies as a fresh careers candidate with the same email → recognized.
|
|
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
|
|
expect("ok" in res && res.id).toBe(exhand.id);
|
|
expect(await db.crewMember.count()).toBe(1); // no duplicate row
|
|
|
|
const after = await db.crewMember.findUniqueOrThrow({ where: { id: exhand.id }, include: { actions: true } });
|
|
expect(after.status).toBe("EX_HAND");
|
|
expect(after.appliedRankId).toBe(rankId);
|
|
expect(after.experienceMonths).toBe(120); // prior history preserved (max)
|
|
expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true);
|
|
});
|
|
|
|
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
|
|
as(managerId, "MANAGER");
|
|
const exhand = await seedExHand({ name: "Returning Ravi" });
|
|
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
|
|
expect("ok" in res && res.id).toBe(exhand.id);
|
|
expect(await db.crewMember.count()).toBe(1);
|
|
});
|
|
|
|
it("does not match a different person → creates a new candidate", async () => {
|
|
as(managerId, "MANAGER");
|
|
await seedExHand({ name: "Ex One", email: "one@ex.com" });
|
|
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
|
|
expect(await db.crewMember.count()).toBe(2);
|
|
});
|
|
|
|
it("lists ex-hands above new candidates by default (AC2)", async () => {
|
|
as(managerId, "MANAGER");
|
|
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
|
|
await seedExHand({ name: "Ex Second" });
|
|
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
|
|
expect(el.props.candidates[0].status).toBe("EX_HAND");
|
|
expect(el.props.candidates[0].name).toBe("Ex Second");
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|