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>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user