test(api): add 36 tests for insights anomalies and resource identifier read
Insights: budget burn rate, staffing gaps, timeline overruns, utilization thresholds, summary counts, sorting. Resource: resolveByIdentifier, getHoverCard, getById, getByEid with alias fallback, getByIdentifierDetail mapping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,633 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildInsightSnapshot,
|
||||
type Anomaly,
|
||||
type InsightsDbAccess,
|
||||
type InsightSnapshot,
|
||||
} from "../router/insights-anomalies.js";
|
||||
|
||||
// Fixed reference date for all deterministic tests.
|
||||
// now = 2026-04-10 (Friday)
|
||||
// periodStart = 2026-04-01, periodEnd = 2026-04-30
|
||||
// twoWeeksFromNow = 2026-04-24
|
||||
// countBusinessDays(2026-01-01, 2026-12-31) = 261
|
||||
// countBusinessDays(2026-01-01, 2026-04-10) = 72
|
||||
// expectedBurnRate = 72 / 261 ≈ 0.2759 (≈28%)
|
||||
const NOW = new Date("2026-04-10");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InsightProjectRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
budgetCents: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
demandRequirements: {
|
||||
headcount: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
_count: { assignments: number };
|
||||
}[];
|
||||
assignments: {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
dailyCostCents: number;
|
||||
status: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface InsightResourceRecord {
|
||||
id: string;
|
||||
displayName: string;
|
||||
availability: unknown;
|
||||
}
|
||||
|
||||
interface InsightAssignmentLoadRecord {
|
||||
resourceId: string;
|
||||
hoursPerDay: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDb(
|
||||
projects: InsightProjectRecord[] = [],
|
||||
resources: InsightResourceRecord[] = [],
|
||||
assignments: InsightAssignmentLoadRecord[] = [],
|
||||
): InsightsDbAccess {
|
||||
return {
|
||||
project: { findMany: vi.fn().mockResolvedValue(projects) },
|
||||
resource: { findMany: vi.fn().mockResolvedValue(resources) },
|
||||
assignment: { findMany: vi.fn().mockResolvedValue(assignments) },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reusable project factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Base project with no anomaly indicators (zero budget, no demands, no assignments). */
|
||||
function baseProject(overrides: Partial<InsightProjectRecord> = {}): InsightProjectRecord {
|
||||
return {
|
||||
id: "p1",
|
||||
name: "Base Project",
|
||||
budgetCents: 0,
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-12-31"),
|
||||
demandRequirements: [],
|
||||
assignments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Project fixture designed to trigger a budget WARNING.
|
||||
*
|
||||
* Budget = 1,000,000 cents. One assignment: dailyCostCents=5000 from 2026-01-01 to 2026-04-10.
|
||||
* assignmentDays = countBizDays(2026-01-01, min(2026-04-10, now)) = 72
|
||||
* totalCostCents = 5000 * 72 = 360,000
|
||||
* actualBurnRate = 360,000 / 1,000,000 = 0.36
|
||||
* expectedBurnRate ≈ 0.2759
|
||||
* 0.36 > 0.2759 * 1.2 = 0.3311 → WARNING
|
||||
* 0.36 > 0.2759 * 1.5 = 0.4138 → NO (not critical)
|
||||
*/
|
||||
const budgetWarningProject: InsightProjectRecord = {
|
||||
id: "p-budget-warn",
|
||||
name: "Budget Warning",
|
||||
budgetCents: 1_000_000,
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-12-31"),
|
||||
demandRequirements: [],
|
||||
assignments: [
|
||||
{
|
||||
resourceId: "r1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-04-10"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 5_000,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Project fixture designed to trigger a budget CRITICAL.
|
||||
*
|
||||
* dailyCostCents=6000; actualBurnRate = 6000*72/1,000,000 = 0.432
|
||||
* 0.432 > 0.2759 * 1.5 = 0.4138 → CRITICAL
|
||||
*/
|
||||
const budgetCriticalProject: InsightProjectRecord = {
|
||||
id: "p-budget-crit",
|
||||
name: "Budget Critical",
|
||||
budgetCents: 1_000_000,
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-12-31"),
|
||||
demandRequirements: [],
|
||||
assignments: [
|
||||
{
|
||||
resourceId: "r1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-04-10"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 6_000,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Project fixture with on-track burn rate (no budget anomaly).
|
||||
*
|
||||
* dailyCostCents=3000; actualBurnRate = 3000*72/1,000,000 = 0.216
|
||||
* 0.216 ≤ 0.2759 * 1.2 = 0.3311 → NO anomaly
|
||||
*/
|
||||
const budgetOnTrackProject: InsightProjectRecord = {
|
||||
id: "p-budget-ok",
|
||||
name: "Budget OK",
|
||||
budgetCents: 1_000_000,
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-12-31"),
|
||||
demandRequirements: [],
|
||||
assignments: [
|
||||
{
|
||||
resourceId: "r1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-04-10"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 3_000,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Standard resource with 8 h/day availability (5×8 = 40 h/week). */
|
||||
const resource8h: InsightResourceRecord = {
|
||||
id: "r1",
|
||||
displayName: "Busy Dev",
|
||||
availability: { mon: 8, tue: 8, wed: 8, thu: 8, fri: 8 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildInsightSnapshot", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Empty data
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("empty data", () => {
|
||||
it("returns empty anomalies and a zero summary when there is no data", async () => {
|
||||
const snapshot = await buildInsightSnapshot(makeDb(), NOW);
|
||||
|
||||
expect(snapshot.anomalies).toHaveLength(0);
|
||||
expect(snapshot.summary).toEqual({
|
||||
total: 0,
|
||||
criticalCount: 0,
|
||||
budget: 0,
|
||||
staffing: 0,
|
||||
timeline: 0,
|
||||
utilization: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Budget anomalies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("budget anomalies", () => {
|
||||
it("produces no anomaly when the burn rate is on track (actual <= expected * 1.2)", async () => {
|
||||
const snapshot = await buildInsightSnapshot(makeDb([budgetOnTrackProject]), NOW);
|
||||
expect(snapshot.anomalies.filter((a) => a.type === "budget")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("produces a warning anomaly when burn rate is 20–50% over expected", async () => {
|
||||
const snapshot = await buildInsightSnapshot(makeDb([budgetWarningProject]), NOW);
|
||||
const budgetAnomalies = snapshot.anomalies.filter((a) => a.type === "budget");
|
||||
|
||||
expect(budgetAnomalies).toHaveLength(1);
|
||||
expect(budgetAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "budget",
|
||||
severity: "warning",
|
||||
entityId: "p-budget-warn",
|
||||
entityName: "Budget Warning",
|
||||
});
|
||||
expect(budgetAnomalies[0]?.message).toContain("36% spent at 28% timeline");
|
||||
});
|
||||
|
||||
it("produces a critical anomaly when burn rate is >50% over expected", async () => {
|
||||
const snapshot = await buildInsightSnapshot(makeDb([budgetCriticalProject]), NOW);
|
||||
const budgetAnomalies = snapshot.anomalies.filter((a) => a.type === "budget");
|
||||
|
||||
expect(budgetAnomalies).toHaveLength(1);
|
||||
expect(budgetAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "budget",
|
||||
severity: "critical",
|
||||
entityId: "p-budget-crit",
|
||||
});
|
||||
});
|
||||
|
||||
it("produces no budget anomaly when budgetCents is 0", async () => {
|
||||
const project: InsightProjectRecord = {
|
||||
...budgetWarningProject,
|
||||
id: "p-zero-budget",
|
||||
budgetCents: 0,
|
||||
};
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
expect(snapshot.anomalies.filter((a) => a.type === "budget")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Staffing anomalies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("staffing anomalies", () => {
|
||||
it("produces a warning when >30% of positions are unfilled and demand starts within 2 weeks", async () => {
|
||||
// headcount=4, assignments=2 → unfilled=2 → 50% > 30%, ≤60% → warning
|
||||
const project = baseProject({
|
||||
id: "p-staff-warn",
|
||||
name: "Staffing Warning",
|
||||
demandRequirements: [
|
||||
{
|
||||
headcount: 4,
|
||||
startDate: new Date("2026-04-15"), // within 2 weeks (≤ 2026-04-24)
|
||||
endDate: new Date("2026-06-01"), // after now (≥ 2026-04-10)
|
||||
_count: { assignments: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
const staffingAnomalies = snapshot.anomalies.filter((a) => a.type === "staffing");
|
||||
|
||||
expect(staffingAnomalies).toHaveLength(1);
|
||||
expect(staffingAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "staffing",
|
||||
severity: "warning",
|
||||
entityId: "p-staff-warn",
|
||||
entityName: "Staffing Warning",
|
||||
});
|
||||
expect(staffingAnomalies[0]?.message).toContain("2 of 4 positions unfilled");
|
||||
});
|
||||
|
||||
it("produces a critical anomaly when >60% of positions are unfilled", async () => {
|
||||
// headcount=5, assignments=1 → unfilled=4 → 80% > 60% → critical
|
||||
const project = baseProject({
|
||||
id: "p-staff-crit",
|
||||
name: "Staffing Critical",
|
||||
demandRequirements: [
|
||||
{
|
||||
headcount: 5,
|
||||
startDate: new Date("2026-04-15"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
_count: { assignments: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
const staffingAnomalies = snapshot.anomalies.filter((a) => a.type === "staffing");
|
||||
|
||||
expect(staffingAnomalies).toHaveLength(1);
|
||||
expect(staffingAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "staffing",
|
||||
severity: "critical",
|
||||
entityId: "p-staff-crit",
|
||||
});
|
||||
});
|
||||
|
||||
it("produces no staffing anomaly when demand starts beyond 2 weeks", async () => {
|
||||
// startDate=2026-05-01 > twoWeeksFromNow(2026-04-24) → demand excluded
|
||||
const project = baseProject({
|
||||
id: "p-staff-future",
|
||||
demandRequirements: [
|
||||
{
|
||||
headcount: 5,
|
||||
startDate: new Date("2026-05-01"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
_count: { assignments: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
expect(snapshot.anomalies.filter((a) => a.type === "staffing")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("produces no staffing anomaly when unfill rate is ≤30%", async () => {
|
||||
// headcount=4, assignments=3 → unfilled=1 → 25% ≤ 30% → no anomaly
|
||||
const project = baseProject({
|
||||
id: "p-staff-ok",
|
||||
demandRequirements: [
|
||||
{
|
||||
headcount: 4,
|
||||
startDate: new Date("2026-04-15"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
_count: { assignments: 3 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
expect(snapshot.anomalies.filter((a) => a.type === "staffing")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Timeline anomalies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("timeline anomalies", () => {
|
||||
it("produces a warning when an ACTIVE/CONFIRMED assignment extends past the project end date", async () => {
|
||||
const project = baseProject({
|
||||
id: "p-timeline-warn",
|
||||
name: "Timeline Warning",
|
||||
endDate: new Date("2026-06-30"),
|
||||
assignments: [
|
||||
{
|
||||
resourceId: "r1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-07-31"), // past project end
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
const timelineAnomalies = snapshot.anomalies.filter((a) => a.type === "timeline");
|
||||
|
||||
expect(timelineAnomalies).toHaveLength(1);
|
||||
expect(timelineAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "timeline",
|
||||
severity: "warning",
|
||||
entityId: "p-timeline-warn",
|
||||
entityName: "Timeline Warning",
|
||||
});
|
||||
expect(timelineAnomalies[0]?.message).toContain(
|
||||
"1 assignment(s) extend beyond the project end date",
|
||||
);
|
||||
expect(timelineAnomalies[0]?.message).toContain("2026-06-30");
|
||||
});
|
||||
|
||||
it("produces no timeline anomaly when all assignments end before the project end date", async () => {
|
||||
const project = baseProject({
|
||||
id: "p-timeline-ok",
|
||||
endDate: new Date("2026-12-31"),
|
||||
assignments: [
|
||||
{
|
||||
resourceId: "r1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-06-30"), // before project end
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
expect(snapshot.anomalies.filter((a) => a.type === "timeline")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Utilization anomalies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("utilization anomalies", () => {
|
||||
// The utilization query window is 2026-04-01 to 2026-04-30.
|
||||
// Any assignment mocked in the `assignments` array is treated as active in that window.
|
||||
|
||||
it("produces a warning when utilization is between 110% and 130%", async () => {
|
||||
// dailyAvailableHours = 40/5 = 8 h/day
|
||||
// bookedHours = 9 → utilization = Math.round(9/8*100) = 113% → warning
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 9 }]),
|
||||
NOW,
|
||||
);
|
||||
const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization");
|
||||
|
||||
expect(utilAnomalies).toHaveLength(1);
|
||||
expect(utilAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "utilization",
|
||||
severity: "warning",
|
||||
entityId: "r1",
|
||||
entityName: "Busy Dev",
|
||||
});
|
||||
expect(utilAnomalies[0]?.message).toContain("113% utilization");
|
||||
});
|
||||
|
||||
it("produces a critical anomaly when utilization exceeds 130%", async () => {
|
||||
// bookedHours = 11 → utilization = Math.round(11/8*100) = 138% → critical
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 11 }]),
|
||||
NOW,
|
||||
);
|
||||
const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization");
|
||||
|
||||
expect(utilAnomalies).toHaveLength(1);
|
||||
expect(utilAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "utilization",
|
||||
severity: "critical",
|
||||
entityId: "r1",
|
||||
});
|
||||
expect(utilAnomalies[0]?.message).toContain("138% utilization");
|
||||
});
|
||||
|
||||
it("produces a warning when utilization is under 40% but has some bookings", async () => {
|
||||
// bookedHours = 2 → utilization = Math.round(2/8*100) = 25% < 40% → underutil warning
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 2 }]),
|
||||
NOW,
|
||||
);
|
||||
const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization");
|
||||
|
||||
expect(utilAnomalies).toHaveLength(1);
|
||||
expect(utilAnomalies[0]).toMatchObject<Partial<Anomaly>>({
|
||||
type: "utilization",
|
||||
severity: "warning",
|
||||
entityId: "r1",
|
||||
});
|
||||
expect(utilAnomalies[0]?.message).toContain("25% utilization");
|
||||
});
|
||||
|
||||
it("produces no anomaly when resource availability is null", async () => {
|
||||
const nullAvailResource: InsightResourceRecord = {
|
||||
id: "r-null",
|
||||
displayName: "Null Avail",
|
||||
availability: null,
|
||||
};
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb([], [nullAvailResource], [{ resourceId: "r-null", hoursPerDay: 15 }]),
|
||||
NOW,
|
||||
);
|
||||
expect(snapshot.anomalies.filter((a) => a.type === "utilization")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Summary
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("summary counts", () => {
|
||||
it("counts anomalies by type correctly", async () => {
|
||||
// Trigger: 1 budget warning + 1 timeline warning.
|
||||
// Keep endDate=2026-12-31 so the budget math still yields a warning
|
||||
// (same as budgetWarningProject), then add a separate assignment that
|
||||
// extends past that same end date to create the timeline warning.
|
||||
const project: InsightProjectRecord = {
|
||||
...budgetWarningProject,
|
||||
id: "p-summary",
|
||||
name: "Summary Project",
|
||||
// endDate stays 2026-12-31 (inherited from budgetWarningProject spread)
|
||||
assignments: [
|
||||
...budgetWarningProject.assignments,
|
||||
{
|
||||
resourceId: "r2",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2027-01-31"), // extends past 2026-12-31
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
|
||||
|
||||
expect(snapshot.summary.budget).toBe(1);
|
||||
expect(snapshot.summary.timeline).toBe(1);
|
||||
expect(snapshot.summary.staffing).toBe(0);
|
||||
expect(snapshot.summary.utilization).toBe(0);
|
||||
expect(snapshot.summary.total).toBe(2);
|
||||
});
|
||||
|
||||
it("counts only critical-severity anomalies in criticalCount", async () => {
|
||||
// 1 budget warning + 1 budget critical from two separate projects
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb([budgetWarningProject, budgetCriticalProject]),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(snapshot.summary.budget).toBe(2);
|
||||
expect(snapshot.summary.criticalCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Sorting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("sorting", () => {
|
||||
it("places critical anomalies before warning anomalies regardless of insertion order", async () => {
|
||||
// budgetWarningProject triggers a warning; budgetCriticalProject triggers a critical
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb([budgetWarningProject, budgetCriticalProject]),
|
||||
NOW,
|
||||
);
|
||||
|
||||
const anomalies = snapshot.anomalies;
|
||||
expect(anomalies.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const firstCriticalIdx = anomalies.findIndex((a) => a.severity === "critical");
|
||||
const lastWarningIdx = anomalies.map((a) => a.severity).lastIndexOf("warning");
|
||||
|
||||
expect(firstCriticalIdx).toBeLessThan(lastWarningIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Combined scenarios
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("combined scenarios", () => {
|
||||
it("detects budget, timeline, and utilization anomalies in a single call", async () => {
|
||||
// Keep endDate=2026-12-31 so the budget warn math holds, and add a
|
||||
// timeline-overrun assignment that ends in 2027.
|
||||
const combinedProject: InsightProjectRecord = {
|
||||
...budgetWarningProject,
|
||||
id: "p-combined",
|
||||
name: "Combined Project",
|
||||
assignments: [
|
||||
// Budget-triggering assignment (same as budgetWarningProject)
|
||||
...budgetWarningProject.assignments,
|
||||
// Timeline-overrun assignment (past 2026-12-31)
|
||||
{
|
||||
resourceId: "r1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2027-01-31"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb(
|
||||
[combinedProject],
|
||||
[resource8h],
|
||||
[{ resourceId: "r1", hoursPerDay: 11 }], // utilization critical
|
||||
),
|
||||
NOW,
|
||||
);
|
||||
|
||||
const types = snapshot.anomalies.map((a) => a.type);
|
||||
expect(types).toContain("budget");
|
||||
expect(types).toContain("timeline");
|
||||
expect(types).toContain("utilization");
|
||||
expect(snapshot.summary.total).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("detects staffing, timeline, and utilization anomalies simultaneously for different entities", async () => {
|
||||
const staffingProject = baseProject({
|
||||
id: "p-multi",
|
||||
name: "Multi Anomaly Project",
|
||||
endDate: new Date("2026-06-30"),
|
||||
demandRequirements: [
|
||||
{
|
||||
headcount: 5,
|
||||
startDate: new Date("2026-04-15"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
_count: { assignments: 1 }, // 80% unfilled → critical
|
||||
},
|
||||
],
|
||||
assignments: [
|
||||
{
|
||||
resourceId: "r1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-07-31"), // extends past project end
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await buildInsightSnapshot(
|
||||
makeDb(
|
||||
[staffingProject],
|
||||
[resource8h],
|
||||
[{ resourceId: "r1", hoursPerDay: 9 }], // 113% → utilization warning
|
||||
),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(snapshot.summary.staffing).toBeGreaterThanOrEqual(1);
|
||||
expect(snapshot.summary.timeline).toBeGreaterThanOrEqual(1);
|
||||
expect(snapshot.summary.utilization).toBeGreaterThanOrEqual(1);
|
||||
expect(snapshot.summary.total).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Critical staffing anomaly must come before warnings in sorted output
|
||||
const criticalIdx = snapshot.anomalies.findIndex((a) => a.severity === "critical");
|
||||
expect(criticalIdx).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock resource read shared
|
||||
const resolveResourceIdentifierSnapshotMock = vi.fn();
|
||||
const readResourceByIdentifierDetailSnapshotMock = vi.fn();
|
||||
|
||||
vi.mock("../router/resource-read-shared.js", () => ({
|
||||
resolveResourceIdentifierSnapshot: (...args: unknown[]) =>
|
||||
resolveResourceIdentifierSnapshotMock(...args),
|
||||
readResourceByIdentifierDetailSnapshot: (...args: unknown[]) =>
|
||||
readResourceByIdentifierDetailSnapshotMock(...args),
|
||||
ResourceDirectoryQuerySchema: {},
|
||||
ResourceListQuerySchema: {},
|
||||
}));
|
||||
|
||||
// Mock resource read models
|
||||
vi.mock("../router/resource-read-models.js", () => ({
|
||||
mapResourceDetail: (r: unknown) => ({ ...(r as Record<string, unknown>), _mapped: true }),
|
||||
}));
|
||||
|
||||
// Mock anonymization
|
||||
const anonymizeResourceMock = vi.fn().mockImplementation((r: unknown) => r);
|
||||
const getAnonymizationDirectoryMock = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("../lib/anonymization.js", () => ({
|
||||
anonymizeResource: (...args: unknown[]) => anonymizeResourceMock(...args),
|
||||
getAnonymizationDirectory: (...args: unknown[]) => getAnonymizationDirectoryMock(...args),
|
||||
}));
|
||||
|
||||
// Mock resource access
|
||||
vi.mock("../lib/resource-access.js", () => ({
|
||||
assertCanReadResource: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock selects
|
||||
vi.mock("../db/selects.js", () => ({
|
||||
ROLE_BRIEF_SELECT: { id: true, name: true },
|
||||
}));
|
||||
|
||||
import { createCallerFactory, createTRPCRouter } from "../trpc.js";
|
||||
import { resourceIdentifierReadProcedures } from "../router/resource-identifier-read.js";
|
||||
|
||||
const router = createTRPCRouter(resourceIdentifierReadProcedures);
|
||||
const createCaller = createCallerFactory(router);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "user@example.com", name: "User", image: null },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: { id: "user_1", systemRole: SystemRole.MANAGER, permissionOverrides: null },
|
||||
});
|
||||
}
|
||||
|
||||
const mockResource = {
|
||||
id: "resource_1",
|
||||
displayName: "Test User",
|
||||
eid: "E001",
|
||||
email: "test@example.com",
|
||||
chapter: "Engineering",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 7500,
|
||||
currency: "EUR",
|
||||
chargeabilityTarget: 80,
|
||||
skills: [],
|
||||
availability: { mon: 8, tue: 8, wed: 8, thu: 8, fri: 8 },
|
||||
isActive: true,
|
||||
resourceType: "INTERNAL",
|
||||
areaRole: { id: "role_1", name: "Developer" },
|
||||
country: { name: "Germany", code: "DE" },
|
||||
managementLevel: { name: "Senior" },
|
||||
userId: "user_1",
|
||||
blueprint: null,
|
||||
resourceRoles: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
anonymizeResourceMock.mockImplementation((r: unknown) => r);
|
||||
getAnonymizationDirectoryMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
// ─── resolveByIdentifier ──────────────────────────────────────────────────────
|
||||
|
||||
describe("resourceIdentifierRead.resolveByIdentifier", () => {
|
||||
it("returns resource when resolved successfully", async () => {
|
||||
resolveResourceIdentifierSnapshotMock.mockResolvedValue(mockResource);
|
||||
const caller = createProtectedCaller({});
|
||||
|
||||
const result = await caller.resolveByIdentifier({ identifier: "E001" });
|
||||
|
||||
expect(result).toEqual(mockResource);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when result has 'error' key", async () => {
|
||||
resolveResourceIdentifierSnapshotMock.mockResolvedValue({ error: "Resource not found: E999" });
|
||||
const caller = createProtectedCaller({});
|
||||
|
||||
await expect(caller.resolveByIdentifier({ identifier: "E999" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes identifier to resolveResourceIdentifierSnapshot", async () => {
|
||||
resolveResourceIdentifierSnapshotMock.mockResolvedValue(mockResource);
|
||||
const caller = createProtectedCaller({});
|
||||
|
||||
await caller.resolveByIdentifier({ identifier: "Test User" });
|
||||
|
||||
expect(resolveResourceIdentifierSnapshotMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dbUser: expect.any(Object) }),
|
||||
"Test User",
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getHoverCard ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resourceIdentifierRead.getHoverCard", () => {
|
||||
it("returns selected fields for a found resource", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(mockResource),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
const result = await caller.getHoverCard({ id: "resource_1" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockResource.id,
|
||||
displayName: mockResource.displayName,
|
||||
eid: mockResource.eid,
|
||||
chapter: mockResource.chapter,
|
||||
lcrCents: mockResource.lcrCents,
|
||||
ucrCents: mockResource.ucrCents,
|
||||
currency: mockResource.currency,
|
||||
chargeabilityTarget: mockResource.chargeabilityTarget,
|
||||
isActive: mockResource.isActive,
|
||||
resourceType: mockResource.resourceType,
|
||||
areaRole: mockResource.areaRole,
|
||||
country: mockResource.country,
|
||||
managementLevel: mockResource.managementLevel,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when findUnique returns null", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.getHoverCard({ id: "nonexistent" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("anonymizes the resource before returning", async () => {
|
||||
const anonymized = { ...mockResource, displayName: "Anonymous", eid: "ANON" };
|
||||
anonymizeResourceMock.mockReturnValue(anonymized);
|
||||
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(mockResource),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
const result = await caller.getHoverCard({ id: "resource_1" });
|
||||
|
||||
expect(anonymizeResourceMock).toHaveBeenCalledWith(mockResource, null);
|
||||
expect(result.displayName).toBe("Anonymous");
|
||||
expect(result.eid).toBe("ANON");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getByIdentifierDetail ────────────────────────────────────────────────────
|
||||
|
||||
describe("resourceIdentifierRead.getByIdentifierDetail", () => {
|
||||
it("maps resource detail when found (adds _mapped: true)", async () => {
|
||||
readResourceByIdentifierDetailSnapshotMock.mockResolvedValue(mockResource);
|
||||
const caller = createProtectedCaller({});
|
||||
|
||||
const result = await caller.getByIdentifierDetail({ identifier: "E001" });
|
||||
|
||||
expect(result).toMatchObject({ ...mockResource, _mapped: true });
|
||||
});
|
||||
|
||||
it("returns error result directly when has 'error' key", async () => {
|
||||
const errorResult = { error: "Resource not found: E999", suggestions: [] };
|
||||
readResourceByIdentifierDetailSnapshotMock.mockResolvedValue(errorResult);
|
||||
const caller = createProtectedCaller({});
|
||||
|
||||
const result = await caller.getByIdentifierDetail({ identifier: "E999" });
|
||||
|
||||
expect(result).toEqual(errorResult);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getById ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resourceIdentifierRead.getById", () => {
|
||||
it("returns resource with isOwnedByCurrentUser=true when userId matches", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(mockResource),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
const result = await caller.getById({ id: "resource_1" });
|
||||
|
||||
expect(result).toMatchObject({ ...mockResource, isOwnedByCurrentUser: true });
|
||||
});
|
||||
|
||||
it("returns isOwnedByCurrentUser=false when userId doesn't match", async () => {
|
||||
const otherResource = { ...mockResource, userId: "other_user" };
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(otherResource),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
const result = await caller.getById({ id: "resource_1" });
|
||||
|
||||
expect(result).toMatchObject({ isOwnedByCurrentUser: false });
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when not found", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.getById({ id: "nonexistent" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getByEid ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resourceIdentifierRead.getByEid", () => {
|
||||
it("returns resource when found by eid", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(mockResource),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
const result = await caller.getByEid({ eid: "E001" });
|
||||
|
||||
expect(result).toEqual(mockResource);
|
||||
expect(db.resource.findUnique).toHaveBeenCalledWith({ where: { eid: "E001" } });
|
||||
});
|
||||
|
||||
it("falls back to anonymization directory alias when eid not found directly", async () => {
|
||||
const aliasMap = new Map([["e001-alias", "resource_1"]]);
|
||||
const directory = { byAliasEid: aliasMap, byResourceId: new Map() };
|
||||
getAnonymizationDirectoryMock.mockResolvedValue(directory);
|
||||
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(mockResource),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
const result = await caller.getByEid({ eid: "E001-Alias" });
|
||||
|
||||
expect(result).toEqual(mockResource);
|
||||
expect(db.resource.findUnique).toHaveBeenNthCalledWith(2, { where: { id: "resource_1" } });
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when both lookups fail", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.getByEid({ eid: "UNKNOWN" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getByIdentifier ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("resourceIdentifierRead.getByIdentifier", () => {
|
||||
it("delegates to resolveResourceIdentifierSnapshot", async () => {
|
||||
resolveResourceIdentifierSnapshotMock.mockResolvedValue(mockResource);
|
||||
const caller = createProtectedCaller({});
|
||||
|
||||
await caller.getByIdentifier({ identifier: "E001" });
|
||||
|
||||
expect(resolveResourceIdentifierSnapshotMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dbUser: expect.any(Object) }),
|
||||
"E001",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns result directly", async () => {
|
||||
const errorResult = { error: "Resource not found: E999" };
|
||||
resolveResourceIdentifierSnapshotMock.mockResolvedValue(errorResult);
|
||||
const caller = createProtectedCaller({});
|
||||
|
||||
const result = await caller.getByIdentifier({ identifier: "E999" });
|
||||
|
||||
expect(result).toEqual(errorResult);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user