From 1d4e5c62b0ecfd5f5ec411f10080a8762623345f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 00:41:09 +0200 Subject: [PATCH] test(api): cover assistant insights and scenarios --- ...assistant-tools-insights-anomalies.test.ts | 142 ++++++++++++ ...t-tools-insights-scenarios-test-helpers.ts | 28 +++ .../assistant-tools-insights-summary.test.ts | 139 +++++++++++ .../assistant-tools-scenarios.test.ts | 215 ++++++++++++++++++ 4 files changed, 524 insertions(+) create mode 100644 packages/api/src/__tests__/assistant-tools-insights-anomalies.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-insights-scenarios-test-helpers.ts create mode 100644 packages/api/src/__tests__/assistant-tools-insights-summary.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-scenarios.test.ts diff --git a/packages/api/src/__tests__/assistant-tools-insights-anomalies.test.ts b/packages/api/src/__tests__/assistant-tools-insights-anomalies.test.ts new file mode 100644 index 0000000..73d667c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-insights-anomalies.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listAssignmentBookings } from "@capakraken/application"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + approveEstimateVersion: vi.fn(), + cloneEstimate: vi.fn(), + commitDispoImportBatch: vi.fn(), + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + assessDispoImportReadiness: vi.fn(), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), + getDashboardDemand: vi.fn().mockResolvedValue([]), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardTopValueResources: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + stageDispoImportBatch: vi.fn(), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-insights-scenarios-test-helpers.js"; + +describe("assistant insight anomaly tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + }); + + it("returns anomaly details and count from the insights-backed detector", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-29T12:00:00.000Z")); + + try { + const ctx = createToolContext({ + project: { + findMany: vi.fn().mockResolvedValue([ + { + id: "project_1", + name: "Apollo", + budgetCents: 100_000, + startDate: new Date("2026-03-01T00:00:00.000Z"), + endDate: new Date("2026-03-31T00:00:00.000Z"), + demandRequirements: [ + { + id: "demand_1", + headcount: 3, + startDate: new Date("2026-03-20T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + status: "CONFIRMED", + _count: { assignments: 1 }, + }, + ], + assignments: [ + { + id: "assignment_1", + resourceId: "res_1", + startDate: new Date("2026-03-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 12, + dailyCostCents: 10_000, + status: "ACTIVE", + }, + ], + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Peter Parker", + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + }, + ]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + resourceId: "res_1", + hoursPerDay: 12, + }, + ]), + }, + }); + + const result = await executeTool("detect_anomalies", "{}", ctx); + const parsed = JSON.parse(result.content) as { + count: number; + anomalies: Array<{ type: string; severity: string; entityName: string }>; + }; + + expect(parsed.count).toBe(4); + expect(parsed.anomalies).toEqual([ + expect.objectContaining({ + type: "budget", + severity: "critical", + entityName: "Apollo", + }), + expect.objectContaining({ + type: "staffing", + severity: "critical", + entityName: "Apollo", + }), + expect.objectContaining({ + type: "utilization", + severity: "critical", + entityName: "Peter Parker", + }), + expect.objectContaining({ + type: "timeline", + severity: "warning", + entityName: "Apollo", + }), + ]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-insights-scenarios-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-insights-scenarios-test-helpers.ts new file mode 100644 index 0000000..20c735c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-insights-scenarios-test-helpers.ts @@ -0,0 +1,28 @@ +import { SystemRole, type PermissionKey } from "@capakraken/shared"; +import type { ToolContext } from "../router/assistant-tools.js"; + +export function createToolContext( + db: Record, + options?: { + permissions?: PermissionKey[]; + userRole?: SystemRole; + }, +): ToolContext { + const userRole = options?.userRole ?? SystemRole.ADMIN; + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(options?.permissions ?? []), + session: { + user: { email: "assistant@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +} diff --git a/packages/api/src/__tests__/assistant-tools-insights-summary.test.ts b/packages/api/src/__tests__/assistant-tools-insights-summary.test.ts new file mode 100644 index 0000000..db30331 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-insights-summary.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + approveEstimateVersion: vi.fn(), + cloneEstimate: vi.fn(), + commitDispoImportBatch: vi.fn(), + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + assessDispoImportReadiness: vi.fn(), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), + getDashboardDemand: vi.fn().mockResolvedValue([]), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardTopValueResources: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + stageDispoImportBatch: vi.fn(), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { SystemRole } from "@capakraken/shared"; +import { createToolContext } from "./assistant-tools-insights-scenarios-test-helpers.js"; + +describe("assistant insights summary tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes insights summary reads through the insights router path", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([ + { + budgetCents: 100_000, + startDate: new Date("2026-03-01T00:00:00.000Z"), + endDate: new Date("2026-03-31T00:00:00.000Z"), + demandRequirements: [ + { + headcount: 3, + startDate: new Date("2026-03-20T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + _count: { assignments: 1 }, + }, + ], + assignments: [ + { + resourceId: "res_1", + startDate: new Date("2026-03-01T00:00:00.000Z"), + endDate: new Date("2026-03-31T00:00:00.000Z"), + hoursPerDay: 8, + dailyCostCents: 10_000, + status: "ACTIVE", + }, + ], + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }, + ]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + resourceId: "res_1", + hoursPerDay: 2, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool("get_insights_summary", "{}", ctx); + + expect(db.project.findMany).toHaveBeenCalledWith({ + where: { status: { in: ["ACTIVE", "DRAFT"] } }, + include: { + demandRequirements: { + select: { + headcount: true, + startDate: true, + endDate: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + select: { + resourceId: true, + startDate: true, + endDate: true, + hoursPerDay: true, + dailyCostCents: true, + status: true, + }, + }, + }, + }); + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + select: { id: true, displayName: true, availability: true }, + }); + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: { + status: { in: ["ACTIVE", "CONFIRMED"] }, + startDate: { lte: expect.any(Date) }, + endDate: { gte: expect.any(Date) }, + }, + select: { resourceId: true, hoursPerDay: true }, + }); + expect(JSON.parse(result.content)).toEqual({ + total: 3, + criticalCount: 2, + budget: 1, + staffing: 1, + timeline: 0, + utilization: 1, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-scenarios.test.ts b/packages/api/src/__tests__/assistant-tools-scenarios.test.ts new file mode 100644 index 0000000..61430d2 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-scenarios.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listAssignmentBookings } from "@capakraken/application"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + approveEstimateVersion: vi.fn(), + cloneEstimate: vi.fn(), + commitDispoImportBatch: vi.fn(), + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + assessDispoImportReadiness: vi.fn(), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), + getDashboardDemand: vi.fn().mockResolvedValue([]), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardTopValueResources: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + stageDispoImportBatch: vi.fn(), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-insights-scenarios-test-helpers.js"; + +describe("assistant scenario tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + }); + + it("routes scenario simulation through the scenario router path", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Gelddruckmaschine", + budgetCents: 200_000, + orderType: "CHARGEABLE", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-30T00:00:00.000Z"), + }), + }, + assignment: { + findMany: vi + .fn() + .mockResolvedValueOnce([ + { + id: "assign_1", + resourceId: "res_1", + projectId: "project_1", + hoursPerDay: 4, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + status: "ACTIVE", + roleId: null, + role: "Pipeline TD", + resource: { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + lcrCents: 10_000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + chargeabilityTarget: 80, + skills: [{ skill: "Houdini" }], + countryId: null, + federalState: null, + metroCityId: null, + country: null, + metroCity: null, + }, + roleEntity: null, + }, + ]) + .mockResolvedValueOnce([ + { + id: "assign_1", + resourceId: "res_1", + projectId: "project_1", + hoursPerDay: 4, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + lcrCents: 10_000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + chargeabilityTarget: 80, + skills: [{ skill: "Houdini" }], + countryId: null, + federalState: null, + metroCityId: null, + country: null, + metroCity: null, + }, + ]), + }, + role: { + findMany: vi.fn(), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "simulate_scenario", + JSON.stringify({ + projectId: "project_1", + changes: [ + { + assignmentId: "assign_1", + resourceId: "res_1", + startDate: "2026-04-01", + endDate: "2026-04-03", + hoursPerDay: 6, + }, + ], + }), + ctx, + ); + + expect(db.assignment.findMany).toHaveBeenNthCalledWith(1, { + where: { projectId: "project_1", status: { not: "CANCELLED" } }, + include: { + resource: { + select: { + id: true, + displayName: true, + eid: true, + lcrCents: true, + availability: true, + chargeabilityTarget: true, + skills: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true } }, + metroCity: { select: { name: true } }, + }, + }, + }, + }); + expect(db.assignment.findMany).toHaveBeenNthCalledWith(2, { + where: { + resourceId: { in: ["res_1"] }, + status: { not: "CANCELLED" }, + }, + select: { + id: true, + resourceId: true, + projectId: true, + hoursPerDay: true, + startDate: true, + endDate: true, + }, + }); + expect(db.role.findMany).not.toHaveBeenCalled(); + expect(JSON.parse(result.content)).toEqual({ + baseline: { + totalCostCents: 120000, + totalHours: 12, + headcount: 1, + skillCount: 1, + totalCost: "1.200,00 EUR", + }, + scenario: { + totalCostCents: 180000, + totalHours: 18, + headcount: 1, + skillCount: 1, + totalCost: "1.800,00 EUR", + }, + delta: { + costCents: 60000, + hours: 6, + headcount: 0, + skillCoveragePct: 100, + cost: "600,00 EUR", + }, + resourceImpacts: [ + { + resourceId: "res_1", + resourceName: "Bruce Banner", + chargeabilityTarget: 80, + currentUtilization: 6.8, + scenarioUtilization: 10.2, + utilizationDelta: 3.4, + isOverallocated: false, + }, + ], + warnings: [], + budgetCents: 200000, + }); + }); +});