Files
CapaKraken/packages/application/src/__tests__/dashboard-extended.test.ts
T
Hartmut cd645c7d55 test(application): add 17 dashboard use-case tests for untested queries
Phase 3b Tier 2: covers skill gaps, project health, top value resources,
and peak times dashboard queries including empty data edge cases,
filtering logic, and authorization guards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 15:54:23 +02:00

720 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from "vitest";
import {
getDashboardSkillGaps,
getDashboardSkillGapSummary,
getDashboardTopValueResources,
getDashboardProjectHealth,
getDashboardPeakTimes,
} from "../index.js";
// ---------------------------------------------------------------------------
// getDashboardSkillGaps
// ---------------------------------------------------------------------------
describe("getDashboardSkillGaps", () => {
it("computes gaps between open demands and skilled resources", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
role: "Compositor",
roleId: "role_comp",
roleEntity: { name: "Compositor" },
headcount: 3,
metadata: null,
},
{
role: "FX Artist",
roleId: "role_fx",
roleEntity: { name: "FX Artist" },
headcount: 2,
metadata: null,
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
skills: [
{ name: "Compositor", level: 4 },
{ name: "FX Artist", level: 2 },
],
},
{ skills: [{ name: "Compositor", level: 3 }] },
]),
},
};
const result = await getDashboardSkillGaps(db as never);
// FX Artist: demand 2, supply 0 (level 2 < 3) → gap -2 (largest shortage first)
// Compositor: demand 3, supply 2 → gap -1
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ skill: "FX Artist", demand: 2, supply: 0, gap: -2 });
expect(result[1]).toEqual({ skill: "Compositor", demand: 3, supply: 2, gap: -1 });
});
it("returns empty array when there are no open demands", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn(),
},
};
const result = await getDashboardSkillGaps(db as never);
expect(result).toEqual([]);
// resource.findMany should never be called when there are no demands
expect(db.resource.findMany).not.toHaveBeenCalled();
});
it("extracts required skills from demand metadata when present", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
role: "Generalist",
roleId: "role_gen",
roleEntity: { name: "Generalist" },
headcount: 2,
metadata: { requiredSkills: ["Houdini", "Nuke"] },
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{ skills: [{ name: "Houdini", level: 5 }] },
{
skills: [
{ name: "Nuke", level: 3 },
{ name: "Houdini", level: 3 },
],
},
]),
},
};
const result = await getDashboardSkillGaps(db as never);
// Both skills demanded: 2 each
// Houdini supply: 2, gap 0; Nuke supply: 1, gap -1
const nukeRow = result.find((row) => row.skill === "Nuke");
const houdiniRow = result.find((row) => row.skill === "Houdini");
expect(nukeRow).toEqual({ skill: "Nuke", demand: 2, supply: 1, gap: -1 });
expect(houdiniRow).toEqual({ skill: "Houdini", demand: 2, supply: 2, gap: 0 });
// Sorted by gap ascending → Nuke first
expect(result[0]?.skill).toBe("Nuke");
});
it("caps output at 10 rows sorted by largest shortage first", async () => {
const manyDemands = Array.from({ length: 12 }, (_, i) => ({
role: `Role_${i}`,
roleId: `role_${i}`,
roleEntity: { name: `Role_${i}` },
headcount: i + 1,
metadata: null,
}));
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue(manyDemands),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardSkillGaps(db as never);
expect(result).toHaveLength(10);
// All gaps are negative (no supply); sorted by ascending gap → largest demand first
for (let i = 0; i < result.length - 1; i++) {
expect(result[i]?.gap ?? 0).toBeLessThanOrEqual(result[i + 1]?.gap ?? 0);
}
});
});
// ---------------------------------------------------------------------------
// getDashboardSkillGapSummary
// ---------------------------------------------------------------------------
describe("getDashboardSkillGapSummary", () => {
it("returns role gaps, open positions total, skill supply and resource-by-role counts", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
role: "Compositor",
headcount: 3,
roleEntity: { name: "Compositor" },
_count: { assignments: 1 },
},
{
role: "Compositor",
headcount: 2,
roleEntity: { name: "Compositor" },
_count: { assignments: 2 },
},
{
role: "FX Artist",
headcount: 2,
roleEntity: { name: "FX Artist" },
_count: { assignments: 0 },
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
skills: [{ name: "Houdini" }, { skill: "Nuke" }],
areaRole: { name: "FX Artist" },
},
{
skills: [{ name: "Houdini" }],
areaRole: { name: "Compositor" },
},
]),
},
};
const result = await getDashboardSkillGapSummary(db as never);
// Compositor: needed=5, filled=min(1,3)+min(2,2)=1+2=3, gap=2
// FX Artist: needed=2, filled=0, gap=2
expect(result.roleGaps).toHaveLength(2);
expect(result.totalOpenPositions).toBe(4);
const compositorGap = result.roleGaps.find((row) => row.role === "Compositor");
expect(compositorGap).toMatchObject({ needed: 5, filled: 3, gap: 2, fillRate: 60 });
const fxGap = result.roleGaps.find((row) => row.role === "FX Artist");
expect(fxGap).toMatchObject({ needed: 2, filled: 0, gap: 2, fillRate: 0 });
// Skill supply: "houdini" appears twice
expect(result.skillSupplyTop10[0]).toEqual({ skill: "houdini", resourceCount: 2 });
// Resources by role
expect(result.resourcesByRole).toContainEqual({ role: "FX Artist", count: 1 });
expect(result.resourcesByRole).toContainEqual({ role: "Compositor", count: 1 });
});
it("returns empty role gaps and zero open positions when all demands are filled", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
role: "Compositor",
headcount: 2,
roleEntity: { name: "Compositor" },
_count: { assignments: 3 }, // more than headcount → capped at headcount
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardSkillGapSummary(db as never);
expect(result.roleGaps).toEqual([]);
expect(result.totalOpenPositions).toBe(0);
});
it("returns empty summary when no demands exist", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardSkillGapSummary(db as never);
expect(result.roleGaps).toEqual([]);
expect(result.totalOpenPositions).toBe(0);
expect(result.skillSupplyTop10).toEqual([]);
expect(result.resourcesByRole).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getDashboardTopValueResources
// ---------------------------------------------------------------------------
describe("getDashboardTopValueResources", () => {
it("returns resources with value scores for authorised roles", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
scoreVisibleRoles: ["ADMIN", "MANAGER"],
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "E001",
displayName: "Alice",
chapter: "CGI",
valueScore: 95,
valueScoreBreakdown: {
skillDepth: 20,
skillBreadth: 15,
costEfficiency: 20,
chargeability: 20,
experience: 20,
total: 95,
},
valueScoreUpdatedAt: new Date("2026-01-01T00:00:00.000Z"),
lcrCents: 80_000,
country: { code: "DE", name: "Germany" },
federalState: "BY",
metroCity: { name: "Munich" },
},
]),
},
};
const result = await getDashboardTopValueResources(db as never, {
limit: 10,
userRole: "ADMIN",
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: "res_1",
eid: "E001",
displayName: "Alice",
chapter: "CGI",
valueScore: 95,
lcrCents: 80_000,
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
});
expect(result[0]?.valueScoreBreakdown).toMatchObject({ total: 95 });
});
it("returns empty array for roles not in the visible-roles list", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
scoreVisibleRoles: ["ADMIN"],
}),
},
resource: {
findMany: vi.fn(),
},
};
const result = await getDashboardTopValueResources(db as never, {
limit: 10,
userRole: "VIEWER",
});
expect(result).toEqual([]);
expect(db.resource.findMany).not.toHaveBeenCalled();
});
it("falls back to default visible roles when settings are null", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
// Default visible roles are ["ADMIN", "MANAGER"]
const adminResult = await getDashboardTopValueResources(db as never, {
limit: 5,
userRole: "ADMIN",
});
expect(adminResult).toEqual([]);
expect(db.resource.findMany).toHaveBeenCalledOnce();
const viewerResult = await getDashboardTopValueResources(db as never, {
limit: 5,
userRole: "VIEWER",
});
expect(viewerResult).toEqual([]);
// findMany should not have been called for VIEWER
expect(db.resource.findMany).toHaveBeenCalledOnce();
});
it("normalises a malformed valueScoreBreakdown to null", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
scoreVisibleRoles: ["ADMIN"],
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_2",
eid: "E002",
displayName: "Bob",
chapter: null,
valueScore: 42,
valueScoreBreakdown: { corrupted: true }, // not a valid breakdown
valueScoreUpdatedAt: null,
lcrCents: 60_000,
country: null,
federalState: null,
metroCity: null,
},
]),
},
};
const result = await getDashboardTopValueResources(db as never, {
limit: 5,
userRole: "ADMIN",
});
expect(result[0]?.valueScoreBreakdown).toBeNull();
expect(result[0]?.countryCode).toBeNull();
expect(result[0]?.metroCityName).toBeNull();
});
});
// ---------------------------------------------------------------------------
// getDashboardProjectHealth
// ---------------------------------------------------------------------------
describe("getDashboardProjectHealth", () => {
it("computes budget, staffing and timeline health scores for an active project", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-10T00:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
budgetCents: 100_000,
endDate: new Date("2026-12-31T00:00:00.000Z"), // ON_TRACK
clientId: "client_1",
client: { name: "ACME" },
demandRequirements: [
{
id: "dr_1",
headcount: 2,
status: "CONFIRMED",
assignments: [{ id: "a1" }], // 1 of 2 filled
},
],
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
projectId: "proj_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
dailyCostCents: 10_000,
resource: {
id: "res_1",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: null,
federalState: null,
metroCityId: null,
country: null,
metroCity: null,
},
},
]),
},
};
const result = await getDashboardProjectHealth(db as never);
expect(result).toHaveLength(1);
const row = result[0]!;
expect(row.shortCode).toBe("ALPHA");
expect(row.clientName).toBe("ACME");
// budgetCents=100_000, spentCents=20_000 (2 working days Mon+Tue)
// pctUsed=20 → budgetHealth=80
expect(row.budgetHealth).toBe(80);
expect(row.spentCents).toBe(20_000);
expect(row.budgetUtilizationPercent).toBe(20);
// staffingHealth: 1 filled / 2 needed = 50
expect(row.staffingHealth).toBe(50);
// timelineHealth: end date is in the future → 100
expect(row.timelineHealth).toBe(100);
expect(row.timelineStatus).toBe("ON_TRACK");
// compositeScore = round((80+50+100)/3) = round(76.67) = 77
expect(row.compositeScore).toBe(77);
expect(row.demandHeadcountTotal).toBe(2);
expect(row.demandHeadcountFilled).toBe(1);
expect(row.demandHeadcountOpen).toBe(1);
} finally {
vi.useRealTimers();
}
});
it("returns empty array when no active projects exist", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn(),
},
};
const result = await getDashboardProjectHealth(db as never);
expect(result).toEqual([]);
expect(db.assignment.findMany).not.toHaveBeenCalled();
});
it("marks a project OVERDUE and sets timelineHealth=0 when end date has passed", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-10T00:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_2",
name: "Beta",
shortCode: "BETA",
status: "ACTIVE",
budgetCents: null,
endDate: new Date("2026-01-01T00:00:00.000Z"), // in the past
clientId: null,
client: null,
demandRequirements: [],
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardProjectHealth(db as never);
expect(result).toHaveLength(1);
const row = result[0]!;
expect(row.timelineHealth).toBe(0);
expect(row.timelineStatus).toBe("OVERDUE");
// No budget defined → budgetHealth=100, no staffing demands → staffingHealth=100
// compositeScore = round((100+100+0)/3) = 67
expect(row.compositeScore).toBe(67);
} finally {
vi.useRealTimers();
}
});
it("sorts results by composite score ascending (worst-health project first)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-10T00:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_good",
name: "Healthy",
shortCode: "GOOD",
status: "ACTIVE",
budgetCents: 100_000,
endDate: new Date("2026-12-31T00:00:00.000Z"),
clientId: null,
client: null,
demandRequirements: [],
},
{
id: "proj_bad",
name: "Overdue",
shortCode: "BAD",
status: "ACTIVE",
budgetCents: 1_000,
endDate: new Date("2025-01-01T00:00:00.000Z"), // overdue
clientId: null,
client: null,
demandRequirements: [
{
id: "dr_1",
headcount: 5,
status: "CONFIRMED",
assignments: [], // 0 filled
},
],
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
projectId: "proj_bad",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
dailyCostCents: 100,
resource: {
id: "res_1",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: null,
federalState: null,
metroCityId: null,
country: null,
metroCity: null,
},
},
]),
},
};
const result = await getDashboardProjectHealth(db as never);
expect(result).toHaveLength(2);
// "bad" project has lower composite score → sorted first
expect(result[0]?.shortCode).toBe("BAD");
expect(result[1]?.shortCode).toBe("GOOD");
expect(result[0]!.compositeScore).toBeLessThan(result[1]!.compositeScore);
} finally {
vi.useRealTimers();
}
});
});
// ---------------------------------------------------------------------------
// getDashboardPeakTimes resource-level grouping
// ---------------------------------------------------------------------------
describe("getDashboardPeakTimes (resource groupBy)", () => {
it("groups booked hours by individual resource name", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
projectId: "proj_1",
resourceId: "res_1",
status: "ACTIVE",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 6,
dailyCostCents: 0,
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "assign_2",
projectId: "proj_1",
resourceId: "res_2",
status: "ACTIVE",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: { id: "res_2", displayName: "Bob", chapter: "Lighting" },
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Alice",
chapter: "CGI",
availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 },
countryId: null,
federalState: null,
metroCityId: null,
country: null,
metroCity: null,
},
{
id: "res_2",
displayName: "Bob",
chapter: "Lighting",
availability: { monday: 4, tuesday: 4, wednesday: 4, thursday: 4, friday: 4 },
countryId: null,
federalState: null,
metroCityId: null,
country: null,
metroCity: null,
},
]),
},
};
const result = await getDashboardPeakTimes(db as never, {
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
granularity: "month",
groupBy: "resource",
});
expect(result).toHaveLength(1);
const period = result[0]!;
expect(period.period).toBe("2026-03");
const alice = period.groups.find((g) => g.name === "Alice");
const bob = period.groups.find((g) => g.name === "Bob");
expect(alice).toBeDefined();
expect(alice?.hours).toBe(6);
expect(alice?.capacityHours).toBe(6);
expect(alice?.utilizationPct).toBe(100);
expect(bob).toBeDefined();
expect(bob?.hours).toBe(4);
expect(bob?.capacityHours).toBe(4);
});
it("returns empty periods list when date range yields no resources and no assignments", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardPeakTimes(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-01T00:00:00.000Z"),
granularity: "week",
groupBy: "project",
});
expect(result).toHaveLength(1);
const period = result[0]!;
expect(period.groups).toEqual([]);
expect(period.totalHours).toBe(0);
expect(period.capacityHours).toBe(0);
expect(period.utilizationPct).toBe(0);
});
});