test(scenario): add unit regression coverage for all four scenario modules
Previously untested business logic — no direct tests existed for the
scenario domain beyond auth guards and delegation stubs.
scenario-shared.test.ts (13 tests)
- roundToTenths: rounding edge cases
- getScenarioAvailability: null/undefined fall through to DEFAULT_AVAILABILITY
- collectScenarioSkillSet: null, empty, lowercase, dedup, whitespace filter
- calculateScenarioEntryHours: null resourceId → calculateAllocation,
non-null → calculateEffectiveBookedHours with context map lookup
scenario-apply.test.ts (6 tests)
- NOT_FOUND guard
- remove:true → CANCELLED status, not counted in appliedCount
- assignmentId without remove → update branch, appliedCount 1
- no assignmentId / no resourceId → skipped, appliedCount 0
- resourceId only → create with computed dailyCostCents (lcrCents × hours)
- mixed changes → correct aggregate appliedCount
scenario-baseline.test.ts (6 tests)
- NOT_FOUND guard
- empty project → zeroed totals
- costCents computed from lcrCents × effective hours
- CANCELLED assignments excluded via findMany WHERE filter
- demands mapped with headcount and roleName from roleEntity
- totalCostCents is sum of all assignment costCents
scenario-simulation.test.ts (6 tests)
- NOT_FOUND guard
- unchanged carry-through → delta.headcount 0
- remove change → delta.headcount -1
- new resource → delta.headcount +1
- budget exceeded → warnings includes /exceeds budget/i
- skill coverage → delta.skillCoveragePct > 0 when scenario adds skills
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyProjectScenario } from "../router/scenario-apply.js";
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
function makeDb(overrides: {
|
||||
projectFindUnique?: ReturnType<typeof vi.fn>;
|
||||
assignmentUpdate?: ReturnType<typeof vi.fn>;
|
||||
assignmentCreate?: ReturnType<typeof vi.fn>;
|
||||
resourceFindUnique?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
return {
|
||||
project: {
|
||||
findUnique: overrides.projectFindUnique ?? vi.fn().mockResolvedValue({ id: "project_1", name: "Test Project" }),
|
||||
},
|
||||
assignment: {
|
||||
update: overrides.assignmentUpdate ?? vi.fn().mockResolvedValue({}),
|
||||
create: overrides.assignmentCreate ?? vi.fn().mockResolvedValue({ id: "new_assignment_1" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: overrides.resourceFindUnique ?? vi.fn().mockResolvedValue({ lcrCents: 100 }),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const baseChange = {
|
||||
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
|
||||
describe("applyProjectScenario", () => {
|
||||
let assignmentUpdate: ReturnType<typeof vi.fn>;
|
||||
let assignmentCreate: ReturnType<typeof vi.fn>;
|
||||
let resourceFindUnique: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
assignmentUpdate = vi.fn().mockResolvedValue({});
|
||||
assignmentCreate = vi.fn().mockResolvedValue({ id: "new_assignment_1" });
|
||||
resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 100 });
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when the project does not exist", async () => {
|
||||
const db = makeDb({
|
||||
projectFindUnique: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyProjectScenario(db, { projectId: "missing_project", changes: [] }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND", message: "Project not found" });
|
||||
});
|
||||
|
||||
it("cancels an assignment when remove:true is supplied", async () => {
|
||||
const db = makeDb({ assignmentUpdate });
|
||||
|
||||
const result = await applyProjectScenario(db, {
|
||||
projectId: "project_1",
|
||||
changes: [{ ...baseChange, assignmentId: "assignment_1", remove: true }],
|
||||
});
|
||||
|
||||
expect(assignmentUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "assignment_1" },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
// Cancels continue early without pushing into `created`, so appliedCount is 0
|
||||
expect(result.appliedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("updates dates and hours when assignmentId is present without remove", async () => {
|
||||
const db = makeDb({ assignmentUpdate });
|
||||
|
||||
const result = await applyProjectScenario(db, {
|
||||
projectId: "project_1",
|
||||
changes: [{ ...baseChange, assignmentId: "assignment_1" }],
|
||||
});
|
||||
|
||||
expect(assignmentUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "assignment_1" },
|
||||
data: {
|
||||
startDate: baseChange.startDate,
|
||||
endDate: baseChange.endDate,
|
||||
hoursPerDay: baseChange.hoursPerDay,
|
||||
},
|
||||
});
|
||||
expect(result.appliedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("skips a change that has neither assignmentId nor resourceId", async () => {
|
||||
const db = makeDb({ assignmentCreate, resourceFindUnique });
|
||||
|
||||
const result = await applyProjectScenario(db, {
|
||||
projectId: "project_1",
|
||||
changes: [{ ...baseChange }],
|
||||
});
|
||||
|
||||
expect(assignmentCreate).not.toHaveBeenCalled();
|
||||
expect(result.appliedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a new assignment when only resourceId is present and computes dailyCostCents", async () => {
|
||||
resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 200 });
|
||||
const db = makeDb({ assignmentCreate, resourceFindUnique });
|
||||
|
||||
const result = await applyProjectScenario(db, {
|
||||
projectId: "project_1",
|
||||
changes: [{ ...baseChange, resourceId: "resource_1", hoursPerDay: 6 }],
|
||||
});
|
||||
|
||||
expect(resourceFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: "resource_1" },
|
||||
select: { lcrCents: true },
|
||||
});
|
||||
|
||||
// dailyCostCents = Math.round(lcrCents * hoursPerDay) = Math.round(200 * 6) = 1200
|
||||
expect(assignmentCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
hoursPerDay: 6,
|
||||
dailyCostCents: 1200,
|
||||
status: "PROPOSED",
|
||||
}),
|
||||
});
|
||||
expect(result.appliedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("counts correctly across multiple mixed changes", async () => {
|
||||
assignmentCreate = vi.fn()
|
||||
.mockResolvedValueOnce({ id: "new_1" })
|
||||
.mockResolvedValueOnce({ id: "new_2" });
|
||||
assignmentUpdate = vi.fn().mockResolvedValue({});
|
||||
|
||||
const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique });
|
||||
|
||||
const result = await applyProjectScenario(db, {
|
||||
projectId: "project_1",
|
||||
changes: [
|
||||
// create 1
|
||||
{ ...baseChange, resourceId: "resource_1" },
|
||||
// create 2
|
||||
{ ...baseChange, resourceId: "resource_2" },
|
||||
// cancel
|
||||
{ ...baseChange, assignmentId: "assignment_1", remove: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(assignmentCreate).toHaveBeenCalledTimes(2);
|
||||
// Cancels continue early without pushing into `created`.
|
||||
// appliedCount = 2 creates + 0 cancel = 2.
|
||||
expect(result.appliedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user