From cd645c7d555a1b0f8156fd74f9aa757a2d276043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 15:54:23 +0200 Subject: [PATCH] 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 --- .../src/__tests__/dashboard-extended.test.ts | 719 ++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 packages/application/src/__tests__/dashboard-extended.test.ts diff --git a/packages/application/src/__tests__/dashboard-extended.test.ts b/packages/application/src/__tests__/dashboard-extended.test.ts new file mode 100644 index 0000000..790a871 --- /dev/null +++ b/packages/application/src/__tests__/dashboard-extended.test.ts @@ -0,0 +1,719 @@ +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); + }); +});