232 lines
6.4 KiB
TypeScript
232 lines
6.4 KiB
TypeScript
import type { ShiftValidationResult } from "@capakraken/shared";
|
|
import { AllocationStatus } from "@capakraken/shared";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
calculateAllocationMock,
|
|
updateAssignmentMock,
|
|
updateDemandRequirementMock,
|
|
validateShiftMock,
|
|
} = vi.hoisted(() => ({
|
|
validateShiftMock: vi.fn(),
|
|
calculateAllocationMock: vi.fn(),
|
|
updateAssignmentMock: vi.fn(),
|
|
updateDemandRequirementMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@capakraken/engine", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@capakraken/engine")>();
|
|
return {
|
|
...actual,
|
|
validateShift: validateShiftMock,
|
|
calculateAllocation: calculateAllocationMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
|
return {
|
|
...actual,
|
|
updateAssignment: updateAssignmentMock,
|
|
updateDemandRequirement: updateDemandRequirementMock,
|
|
};
|
|
});
|
|
|
|
import { buildTimelineProjectShiftEventPayload } from "../router/timeline-shift-support.js";
|
|
import { applyTimelineProjectShift } from "../router/timeline-shift-procedure-support.js";
|
|
|
|
function createValidValidationResult(
|
|
overrides: Partial<ShiftValidationResult> = {},
|
|
): ShiftValidationResult {
|
|
return {
|
|
valid: true,
|
|
errors: [],
|
|
warnings: [],
|
|
conflictDetails: [],
|
|
costImpact: {
|
|
currentTotalCents: 100_000,
|
|
newTotalCents: 112_000,
|
|
deltaCents: 12_000,
|
|
budgetCents: 200_000,
|
|
budgetUtilizationBefore: 50,
|
|
budgetUtilizationAfter: 56,
|
|
wouldExceedBudget: false,
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("timeline shift procedure support", () => {
|
|
it("applies project shift updates and recalculates only staffed assignments", async () => {
|
|
const validation = createValidValidationResult();
|
|
validateShiftMock.mockReturnValue(validation);
|
|
calculateAllocationMock.mockReturnValue({ dailyCostCents: 54_321 });
|
|
updateDemandRequirementMock.mockResolvedValue({});
|
|
updateAssignmentMock.mockResolvedValue({});
|
|
|
|
const updatedProject = {
|
|
id: "project_1",
|
|
startDate: new Date("2026-04-03"),
|
|
endDate: new Date("2026-04-12"),
|
|
};
|
|
const db = {
|
|
project: {
|
|
update: vi.fn().mockResolvedValue(updatedProject),
|
|
},
|
|
auditLog: {
|
|
create: vi.fn().mockResolvedValue({}),
|
|
},
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
calculationRule: {
|
|
findMany: vi.fn().mockResolvedValue([{ id: "rule_1", priority: 1 }]),
|
|
},
|
|
$transaction: vi.fn(async (callback: (tx: typeof db) => unknown) => callback(db)),
|
|
};
|
|
|
|
const context = {
|
|
project: {
|
|
id: "project_1",
|
|
budgetCents: 200_000,
|
|
winProbability: 80,
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-10"),
|
|
},
|
|
demandRequirements: [
|
|
{
|
|
id: "demand_1",
|
|
},
|
|
],
|
|
assignments: [
|
|
{
|
|
id: "assignment_staffed",
|
|
resourceId: "resource_1",
|
|
hoursPerDay: 8,
|
|
metadata: { includeSaturday: true },
|
|
resource: {
|
|
lcrCents: 5_000,
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "assignment_placeholder",
|
|
resourceId: null,
|
|
hoursPerDay: 4,
|
|
metadata: {},
|
|
resource: null,
|
|
},
|
|
],
|
|
shiftPlan: {
|
|
validationAllocations: [
|
|
{
|
|
id: "assignment_1",
|
|
resourceId: "resource_1",
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-05"),
|
|
hoursPerDay: 8,
|
|
percentage: 100,
|
|
role: "Compositing",
|
|
dailyCostCents: 40_000,
|
|
status: AllocationStatus.ACTIVE,
|
|
resource: {
|
|
id: "resource_1",
|
|
displayName: "Alice",
|
|
lcrCents: 5_000,
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
},
|
|
},
|
|
allAllocationsForResource: [],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = await applyTimelineProjectShift({
|
|
db: db as never,
|
|
projectId: "project_1",
|
|
newStartDate: new Date("2026-04-03"),
|
|
newEndDate: new Date("2026-04-12"),
|
|
context: context as never,
|
|
});
|
|
|
|
expect(db.project.update).toHaveBeenCalledWith({
|
|
where: { id: "project_1" },
|
|
data: {
|
|
startDate: new Date("2026-04-03"),
|
|
endDate: new Date("2026-04-12"),
|
|
},
|
|
});
|
|
expect(updateDemandRequirementMock).toHaveBeenCalledWith(
|
|
db,
|
|
"demand_1",
|
|
{
|
|
startDate: new Date("2026-04-03"),
|
|
endDate: new Date("2026-04-12"),
|
|
},
|
|
);
|
|
expect(db.vacation.findMany).toHaveBeenCalledTimes(1);
|
|
expect(db.calculationRule.findMany).toHaveBeenCalledTimes(1);
|
|
expect(calculateAllocationMock).toHaveBeenCalledTimes(1);
|
|
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
db,
|
|
"assignment_staffed",
|
|
{
|
|
startDate: new Date("2026-04-03"),
|
|
endDate: new Date("2026-04-12"),
|
|
dailyCostCents: 54_321,
|
|
},
|
|
);
|
|
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
db,
|
|
"assignment_placeholder",
|
|
{
|
|
startDate: new Date("2026-04-03"),
|
|
endDate: new Date("2026-04-12"),
|
|
},
|
|
);
|
|
expect(db.auditLog.create).toHaveBeenCalledWith({
|
|
data: {
|
|
entityType: "Project",
|
|
entityId: "project_1",
|
|
action: "SHIFT",
|
|
changes: expect.objectContaining({
|
|
before: {
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-10"),
|
|
},
|
|
after: {
|
|
startDate: new Date("2026-04-03"),
|
|
endDate: new Date("2026-04-12"),
|
|
},
|
|
costImpact: validation.costImpact,
|
|
}),
|
|
},
|
|
});
|
|
expect(result).toEqual({
|
|
project: updatedProject,
|
|
validation,
|
|
event: buildTimelineProjectShiftEventPayload({
|
|
projectId: "project_1",
|
|
newStartDate: new Date("2026-04-03"),
|
|
newEndDate: new Date("2026-04-12"),
|
|
validation,
|
|
assignments: context.assignments as never,
|
|
}),
|
|
});
|
|
});
|
|
});
|