cd645c7d55
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>
720 lines
22 KiB
TypeScript
720 lines
22 KiB
TypeScript
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);
|
||
});
|
||
});
|