Files
CapaKraken/packages/api/src/__tests__/dashboard-procedure-support.test.ts
T

729 lines
22 KiB
TypeScript

import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
getDashboardOverview: vi.fn(),
getDashboardPeakTimes: vi.fn(),
getDashboardDemand: vi.fn(),
getDashboardTopValueResources: vi.fn(),
getDashboardChargeabilityOverview: vi.fn(),
getDashboardBudgetForecast: vi.fn(),
getDashboardSkillGaps: vi.fn(),
getDashboardSkillGapSummary: vi.fn(),
getDashboardProjectHealth: vi.fn(),
};
});
vi.mock("../lib/cache.js", () => ({
cacheGet: vi.fn().mockResolvedValue(null),
cacheSet: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/anonymization.js", () => ({
anonymizeResources: vi.fn((resources: unknown[]) => resources.map((resource) => ({ ...resource, anonymized: true }))),
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
}));
import {
getDashboardBudgetForecast,
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardProjectHealth,
getDashboardSkillGapSummary,
getDashboardTopValueResources,
} from "@capakraken/application";
import { cacheGet } from "../lib/cache.js";
import { anonymizeResources } from "../lib/anonymization.js";
import {
getDashboardBudgetForecastDetail,
getDashboardChargeabilityOverviewRead,
getDashboardDetail,
getDashboardProjectHealthDetail,
getDashboardStatisticsDetail,
} from "../router/dashboard-procedure-support.js";
function createContext() {
return {
db: {} as never,
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
dbUser: {
id: "user_1",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
};
}
describe("dashboard procedure support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("derives statistics detail from the canonical overview", async () => {
vi.mocked(getDashboardOverview).mockResolvedValue({
totalResources: 12,
activeResources: 10,
inactiveResources: 2,
totalProjects: 7,
activeProjects: 4,
inactiveProjects: 3,
totalAllocations: 21,
activeAllocations: 18,
cancelledAllocations: 3,
approvedVacations: 6,
totalEstimates: 9,
budgetSummary: {
totalBudgetCents: 1_234_56,
totalCostCents: 654_32,
avgUtilizationPercent: 53,
},
budgetBasis: {
remainingBudgetCents: 58_024,
budgetedProjects: 5,
unbudgetedProjects: 2,
trackedAssignmentCount: 18,
windowStart: null,
windowEnd: null,
},
projectsByStatus: [
{ status: "ACTIVE", count: 4 },
{ status: "DRAFT", count: 2 },
{ status: "DONE", count: 1 },
],
chapterUtilization: [
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
{ chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 },
],
recentActivity: [],
});
const result = await getDashboardStatisticsDetail(createContext());
expect(result).toEqual({
activeResources: 10,
totalProjects: 7,
activeProjects: 4,
totalAllocations: 21,
approvedVacations: 6,
totalEstimates: 9,
totalBudget: "1.234,56 EUR",
projectsByStatus: {
ACTIVE: 4,
DRAFT: 2,
DONE: 1,
},
topChapters: [
{ chapter: "CGI", count: 5 },
{ chapter: "Compositing", count: 3 },
],
});
});
it("builds the isolated skill gap detail section without overview reads", async () => {
vi.mocked(getDashboardSkillGapSummary).mockResolvedValue({
roleGaps: [
{ role: "Pipeline TD", needed: 4, filled: 1, gap: 3, fillRate: 25 },
],
totalOpenPositions: 3,
skillSupplyTop10: [{ skill: "houdini", resourceCount: 5 }],
resourcesByRole: [{ role: "Pipeline TD", count: 2 }],
});
const result = await getDashboardDetail(createContext(), { section: "skill_gaps" });
expect(result).toEqual({
skillGaps: {
totalOpenPositions: 3,
roleGaps: [
{ role: "Pipeline TD", gap: 3, needed: 4, filled: 1, fillRate: 25 },
],
topSkillsInSupply: [{ skill: "houdini", resourceCount: 5 }],
resourcesByRole: [{ role: "Pipeline TD", count: 2 }],
},
});
expect(getDashboardSkillGapSummary).toHaveBeenCalledTimes(1);
expect(getDashboardOverview).not.toHaveBeenCalled();
expect(getDashboardPeakTimes).not.toHaveBeenCalled();
expect(getDashboardDemand).not.toHaveBeenCalled();
expect(getDashboardProjectHealth).not.toHaveBeenCalled();
});
it("builds the assistant-facing dashboard detail payload", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-15T00:00:00.000Z"));
vi.mocked(getDashboardOverview).mockResolvedValue({
totalResources: 12,
activeResources: 10,
inactiveResources: 2,
totalProjects: 7,
activeProjects: 4,
inactiveProjects: 3,
totalAllocations: 21,
activeAllocations: 18,
cancelledAllocations: 3,
approvedVacations: 6,
totalEstimates: 9,
budgetSummary: {
totalBudgetCents: 1_234_56,
totalCostCents: 654_32,
avgUtilizationPercent: 53,
},
budgetBasis: {
remainingBudgetCents: 58_024,
budgetedProjects: 5,
unbudgetedProjects: 2,
trackedAssignmentCount: 18,
windowStart: "2026-03-01T00:00:00.000Z",
windowEnd: "2026-06-30T00:00:00.000Z",
},
projectsByStatus: [],
chapterUtilization: [
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
],
recentActivity: [],
});
vi.mocked(getDashboardPeakTimes).mockResolvedValue([
{
period: "2026-03",
totalHours: 160.34,
capacityHours: 200.12,
utilizationPct: 80,
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 1,
resourceCount: 5,
groupCount: 2,
baseAvailableHours: 216,
effectiveAvailableHours: 200.12,
publicHolidayHoursDeduction: 8,
absenceDayEquivalent: 1,
absenceHoursDeduction: 7.88,
calendarLocations: [{ countryCode: "DE", federalState: "BY", metroCityName: "Munich" }],
remainingCapacityHours: 39.78,
overbookedHours: 0,
},
},
]);
vi.mocked(getDashboardTopValueResources).mockResolvedValue([
{
id: "res_1",
eid: "E-001",
displayName: "Ada Lovelace",
chapter: "CGI",
lcrCents: 12345,
valueScore: 98,
valueScoreBreakdown: {
skillDepth: 94,
skillBreadth: 86,
costEfficiency: 82,
chargeability: 88,
experience: 96,
total: 98,
},
valueScoreUpdatedAt: new Date("2026-03-05T00:00:00.000Z"),
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
},
]);
vi.mocked(getDashboardDemand).mockResolvedValue([
{
name: "Apollo",
shortCode: "APO",
requiredFTEs: 3,
resourceCount: 1,
allocatedHours: 80,
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-06-30",
periodWorkingHoursBase: 672,
requiredHours: 2016,
requiredFTEs: 3,
fillPct: 40,
demandSource: "DEMAND_REQUIREMENTS",
calendarLocations: [{ countryCode: "DE", countryName: "Germany" }],
},
},
]);
vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({
rows: [
{
id: "res_1",
eid: "E-001",
displayName: "Ada Lovelace",
chapter: "CGI",
chargeabilityTarget: 78,
actualChargeability: 64,
expectedChargeability: 72,
derivation: {
weeklyAvailabilityHours: 40,
baseWorkingDays: 23,
effectiveWorkingDayEquivalent: 21,
baseAvailableHours: 184,
effectiveAvailableHours: 168,
publicHolidayCount: 1,
publicHolidayWorkdayCount: 1,
publicHolidayHoursDeduction: 8,
absenceDayEquivalent: 1,
absenceHoursDeduction: 8,
actualBookedHours: 107.5,
expectedBookedHours: 121,
targetBookedHours: 131,
unassignedHours: 47,
},
},
],
top: [],
watchlist: [],
month: "2026-03",
});
vi.mocked(getDashboardProjectHealth).mockResolvedValue([
{
id: "project_1",
projectName: "Apollo",
shortCode: "APO",
status: "ACTIVE",
clientId: "client_1",
clientName: "Acme",
budgetHealth: 62,
staffingHealth: 55,
timelineHealth: 48,
compositeScore: 54,
budgetUtilizationPercent: 81,
remainingBudgetCents: 45_000,
demandHeadcountTotal: 5,
demandHeadcountFilled: 3,
demandHeadcountOpen: 2,
demandRequirementCount: 2,
plannedEndDate: new Date("2026-07-31T00:00:00.000Z"),
daysUntilEndDate: 138,
timelineStatus: "DUE_SOON",
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-06-30",
calendarContextCount: 1,
holidayAwareAssignmentCount: 3,
fallbackAssignmentCount: 0,
baseSpentCents: 170_000,
adjustedSpentCents: 165_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 5_000,
absenceDayEquivalent: 0.5,
absenceCostDeductionCents: 3_000,
},
},
]);
vi.mocked(getDashboardSkillGapSummary).mockResolvedValue({
roleGaps: [
{ role: "Pipeline TD", needed: 4, filled: 1, gap: 3, fillRate: 25 },
{ role: "Lighting TD", needed: 2, filled: 1, gap: 1, fillRate: 50 },
],
totalOpenPositions: 4,
skillSupplyTop10: [
{ skill: "houdini", resourceCount: 5 },
{ skill: "nuke", resourceCount: 4 },
],
resourcesByRole: [
{ role: "Compositor", count: 6 },
{ role: "Pipeline TD", count: 2 },
],
});
try {
const result = await getDashboardDetail(createContext(), { section: "all" });
expect(result).toEqual({
peakTimes: [
{
month: "2026-03",
totalHours: 160.3,
totalHoursPerDay: 160.3,
capacityHours: 200.1,
utilizationPct: 80,
calendarContextCount: 1,
calendarLocations: [{ countryCode: "DE", federalState: "BY", metroCityName: "Munich" }],
explainability: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
resourceCount: 5,
groupCount: 2,
baseAvailableHours: 216,
effectiveAvailableHours: 200.12,
publicHolidayHoursDeduction: 8,
absenceDayEquivalent: 1,
absenceHoursDeduction: 7.88,
remainingCapacityHours: 39.78,
overbookedHours: 0,
},
},
],
topResources: [
{
name: "Ada Lovelace",
eid: "E-001",
chapter: "CGI",
lcr: "123,45 EUR",
valueScore: 98,
valueScoreBreakdown: {
skillDepth: 94,
skillBreadth: 86,
costEfficiency: 82,
chargeability: 88,
experience: 96,
total: 98,
},
valueScoreUpdatedAt: "2026-03-05T00:00:00.000Z",
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
},
],
demandPipeline: [
{
project: "Apollo (APO)",
needed: 2,
requiredFTEs: 3,
allocatedResources: 1,
allocatedHours: 80,
calendarLocations: [{ countryCode: "DE", countryName: "Germany" }],
explainability: {
periodStart: "2026-03-01",
periodEnd: "2026-06-30",
periodWorkingHoursBase: 672,
requiredHours: 2016,
fillPct: 40,
demandSource: "DEMAND_REQUIREMENTS",
calendarContextCount: 1,
},
},
],
chargeabilityByChapter: [
{
chapter: "CGI",
headcount: 1,
avgTargetPct: 78,
avgActualPct: 64,
avgExpectedPct: 72,
gapToTargetPct: 14,
avgTarget: "78%",
avgActual: "64%",
avgExpected: "72%",
explainability: {
month: "2026-03",
resourceCount: 1,
derivedHeadcount: 1,
baseAvailableHours: 184,
effectiveAvailableHours: 168,
actualBookedHours: 107.5,
expectedBookedHours: 121,
targetBookedHours: 131,
publicHolidayHoursDeduction: 8,
absenceDayEquivalent: 1,
absenceHoursDeduction: 8,
unassignedHours: 47,
},
},
],
projectHealth: [
{
project: "Apollo (APO)",
status: "ACTIVE",
overall: 54,
rating: "at_risk",
budget: 62,
staffing: 55,
timeline: 48,
timelineStatus: "DUE_SOON",
daysUntilEndDate: 138,
demandHeadcountOpen: 2,
explainability: {
demandHeadcountTotal: 5,
demandHeadcountFilled: 3,
demandHeadcountOpen: 2,
demandRequirementCount: 2,
plannedEndDate: "2026-07-31T00:00:00.000Z",
budgetUtilizationPercent: 81,
remainingBudgetCents: 45_000,
calendarContextCount: 1,
holidayAwareAssignmentCount: 3,
publicHolidayCostDeductionCents: 5_000,
absenceCostDeductionCents: 3_000,
},
},
],
skillGaps: {
totalOpenPositions: 4,
roleGaps: [
{ role: "Pipeline TD", gap: 3, needed: 4, filled: 1, fillRate: 25 },
{ role: "Lighting TD", gap: 1, needed: 2, filled: 1, fillRate: 50 },
],
topSkillsInSupply: [
{ skill: "houdini", resourceCount: 5 },
{ skill: "nuke", resourceCount: 4 },
],
resourcesByRole: [
{ role: "Compositor", count: 6 },
{ role: "Pipeline TD", count: 2 },
],
},
});
expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
includeProposed: false,
topN: 10,
watchlistThreshold: 15,
}),
);
} finally {
vi.useRealTimers();
}
});
it("anonymizes chargeability overview payloads before returning them", async () => {
vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({
avgChargeability: 72,
top: [{ id: "res_1" }],
watchlist: [{ id: "res_2" }],
});
const result = await getDashboardChargeabilityOverviewRead(createContext(), {
includeProposed: false,
topN: 10,
watchlistThreshold: 15,
});
expect(anonymizeResources).toHaveBeenCalledTimes(2);
expect(result).toEqual({
avgChargeability: 72,
top: [{ id: "res_1", anonymized: true }],
watchlist: [{ id: "res_2", anonymized: true }],
});
});
it("reuses cached budget forecast rows for detail payloads", async () => {
vi.mocked(cacheGet).mockResolvedValueOnce([
{
projectId: "project_1",
projectName: "Apollo",
shortCode: "APO",
clientId: "client_1",
clientName: "Acme",
budgetCents: 500_000,
spentCents: 320_000,
remainingCents: 180_000,
burnRate: 120_000,
estimatedExhaustionDate: "2026-05-31",
pctUsed: 64,
activeAssignmentCount: 3,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
activeAssignmentCount: 2,
burnRateCents: 80_000,
},
],
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 1,
baseBurnRateCents: 130_000,
adjustedBurnRateCents: 120_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 5_000,
absenceDayEquivalent: 0.5,
absenceCostDeductionCents: 5_000,
},
},
]);
const result = await getDashboardBudgetForecastDetail(createContext());
expect(getDashboardBudgetForecast).not.toHaveBeenCalled();
expect(result).toEqual({
forecasts: [
{
projectId: "project_1",
projectName: "Apollo",
shortCode: "APO",
clientId: "client_1",
clientName: "Acme",
budget: "5.000,00 EUR",
budgetCents: 500_000,
spent: "3.200,00 EUR",
spentCents: 320_000,
remaining: "1.800,00 EUR",
remainingCents: 180_000,
projected: "5.000,00 EUR",
projectedCents: 500_000,
burnRate: "1.200,00 EUR",
burnRateCents: 120_000,
utilization: "64%",
estimatedExhaustionDate: "2026-05-31",
activeAssignmentCount: 3,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
activeAssignmentCount: 2,
burnRateCents: 80_000,
},
],
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 1,
baseBurnRateCents: 130_000,
adjustedBurnRateCents: 120_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 5_000,
absenceDayEquivalent: 0.5,
absenceCostDeductionCents: 5_000,
},
burnStatus: "on_track",
},
],
});
});
it("reuses cached project health rows for detail payloads", async () => {
vi.mocked(cacheGet).mockResolvedValueOnce([
{
id: "project_1",
projectName: "Apollo",
shortCode: "APO",
status: "ACTIVE",
clientId: "client_1",
clientName: "Acme",
budgetHealth: 78,
staffingHealth: 66,
timelineHealth: 55,
compositeScore: 66,
budgetCents: 500_000,
spentCents: 320_000,
remainingBudgetCents: 180_000,
budgetUtilizationPercent: 64,
demandHeadcountTotal: 5,
demandHeadcountFilled: 3,
demandHeadcountOpen: 2,
demandRequirementCount: 2,
plannedEndDate: new Date("2026-06-30T00:00:00.000Z"),
daysUntilEndDate: 107,
timelineStatus: "ON_TRACK" as const,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
assignmentCount: 3,
spentCents: 320_000,
},
],
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 1,
baseSpentCents: 330_000,
adjustedSpentCents: 320_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 5_000,
absenceDayEquivalent: 0.5,
absenceCostDeductionCents: 5_000,
},
},
]);
const result = await getDashboardProjectHealthDetail(createContext());
expect(getDashboardProjectHealth).not.toHaveBeenCalled();
expect(result).toEqual({
projects: [
{
projectId: "project_1",
projectName: "Apollo",
shortCode: "APO",
status: "ACTIVE",
overall: 66,
budget: 78,
staffing: 66,
timeline: 55,
rating: "at_risk",
budgetBasis: {
budgetCents: 500_000,
spentCents: 320_000,
remainingBudgetCents: 180_000,
budgetUtilizationPercent: 64,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
assignmentCount: 3,
spentCents: 320_000,
},
],
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 1,
baseSpentCents: 330_000,
adjustedSpentCents: 320_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 5_000,
absenceDayEquivalent: 0.5,
absenceCostDeductionCents: 5_000,
},
},
staffingBasis: {
demandHeadcountTotal: 5,
demandHeadcountFilled: 3,
demandHeadcountOpen: 2,
demandRequirementCount: 2,
},
timelineBasis: {
plannedEndDate: "2026-06-30T00:00:00.000Z",
daysUntilEndDate: 107,
timelineStatus: "ON_TRACK",
},
context: {
clientId: "client_1",
clientName: "Acme",
},
},
],
summary: {
healthy: 0,
atRisk: 1,
critical: 0,
},
});
});
});