From 0039a9997a2208c6f1b3a0b8f3bd64b6f028b4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 00:42:02 +0200 Subject: [PATCH] test(api): cover assistant project computation views --- ...nt-tools-project-computation-graph.test.ts | 96 ++++++++++ .../assistant-tools-project-narrative.test.ts | 175 ++++++++++++++++++ ...t-tools-resource-computation-graph.test.ts | 118 ++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 packages/api/src/__tests__/assistant-tools-project-computation-graph.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-project-narrative.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-resource-computation-graph.test.ts diff --git a/packages/api/src/__tests__/assistant-tools-project-computation-graph.test.ts b/packages/api/src/__tests__/assistant-tools-project-computation-graph.test.ts new file mode 100644 index 0000000..6b2c829 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-computation-graph.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-project-test-helpers.js"; + +describe("assistant project read tools - computation graph", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a filtered project computation graph through the assistant", async () => { + const projectRecord = { + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + budgetCents: 100_000, + winProbability: 75, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-02-28T00:00:00.000Z"), + status: "ACTIVE", + responsiblePerson: "Larissa Joos", + }; + + const ctx = createToolContext( + { + project: { + findUnique: vi.fn().mockResolvedValue(projectRecord), + findFirst: vi.fn(), + findUniqueOrThrow: vi.fn().mockResolvedValue(projectRecord), + }, + estimate: { + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + status: "CONFIRMED", + dailyCostCents: 4_000, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-30T00:00:00.000Z"), + hoursPerDay: 4, + }, + ]), + }, + effortRule: { + count: vi.fn().mockResolvedValue(0), + }, + experienceMultiplierRule: { + count: vi.fn().mockResolvedValue(0), + }, + }, + { + permissions: [PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + }, + ); + + const result = await executeTool( + "get_project_computation_graph", + JSON.stringify({ + projectId: "project_1", + domain: "BUDGET", + includeLinks: true, + }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + project: { id: string; shortCode: string; name: string }; + requestedDomain: string; + totalNodeCount: number; + selectedNodeCount: number; + selectedLinkCount: number; + nodes: Array<{ id: string; domain: string }>; + links: Array<{ source: string; target: string }>; + meta: { projectName: string; projectCode: string }; + }; + + expect(parsed.project).toEqual({ + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + }); + expect(parsed.meta).toEqual({ + projectName: "Gelddruckmaschine", + projectCode: "GDM", + }); + expect(parsed.requestedDomain).toBe("BUDGET"); + expect(parsed.totalNodeCount).toBeGreaterThan(parsed.selectedNodeCount); + expect(parsed.selectedNodeCount).toBeGreaterThan(0); + expect(parsed.selectedLinkCount).toBeGreaterThan(0); + expect(parsed.nodes.every((node) => node.domain === "BUDGET")).toBe(true); + expect(parsed.links.length).toBe(parsed.selectedLinkCount); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-narrative.test.ts b/packages/api/src/__tests__/assistant-tools-project-narrative.test.ts new file mode 100644 index 0000000..538b7ba --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-narrative.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_OPENAI_MODEL, 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(), + }; +}); + +vi.mock("../ai-client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createAiClient: vi.fn(() => ({ + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [ + { + message: { + content: "Project is on track overall, but staffing remains partially open.", + }, + }, + ], + }), + }, + }, + })), + loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn) => fn()), + }; +}); + +import { + createToolContext, + executeTool, +} from "./assistant-tools-project-media-test-helpers.js"; + +describe("assistant project media tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes project narrative generation through the real insights router path", async () => { + const projectFindUnique = vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + startDate: new Date("2026-03-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + budgetCents: 250_000_00, + dynamicFields: { existingFlag: true }, + demandRequirements: [ + { + headcount: 2, + _count: { assignments: 1 }, + }, + ], + assignments: [ + { + status: "ACTIVE", + dailyCostCents: 42_000, + startDate: new Date("2026-03-10T00:00:00.000Z"), + endDate: new Date("2026-03-20T00:00:00.000Z"), + resource: { displayName: "Carol Danvers" }, + }, + ], + }); + const projectUpdate = vi.fn().mockResolvedValue({ + id: "project_1", + dynamicFields: { + existingFlag: true, + aiNarrative: "Project is on track overall, but staffing remains partially open.", + }, + }); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + update: projectUpdate, + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + id: "singleton", + aiProvider: "openai", + azureOpenAiApiKey: "sk-test", + azureOpenAiDeployment: DEFAULT_OPENAI_MODEL, + }), + }, + }, + { + userRole: SystemRole.CONTROLLER, + }, + ); + + const result = await executeTool( + "generate_project_narrative", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(projectFindUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + include: { + demandRequirements: { + select: { + id: true, + role: true, + headcount: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + select: { + id: true, + role: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + dailyCostCents: true, + resource: { select: { displayName: true } }, + }, + }, + }, + }); + expect(projectUpdate).toHaveBeenCalledWith({ + where: { id: "project_1" }, + data: { + dynamicFields: expect.objectContaining({ + existingFlag: true, + aiNarrative: "Project is on track overall, but staffing remains partially open.", + aiNarrativeGeneratedAt: expect.any(String), + }), + }, + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + narrative: "Project is on track overall, but staffing remains partially open.", + generatedAt: expect.any(String), + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-resource-computation-graph.test.ts b/packages/api/src/__tests__/assistant-tools-resource-computation-graph.test.ts new file mode 100644 index 0000000..e7fd3f9 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-computation-graph.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-resource-test-helpers.js"; + +describe("assistant resource read tools - computation graph", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a filtered resource computation graph through the assistant", async () => { + const resourceRecord = { + id: "resource_augsburg", + displayName: "Bruce Banner", + eid: "bruce.banner", + fte: 1, + lcrCents: 5_000, + chargeabilityTarget: 80, + countryId: "country_de", + federalState: "BY", + metroCityId: "city_augsburg", + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }, + country: { + id: "country_de", + code: "DE", + name: "Deutschland", + dailyWorkingHours: 8, + scheduleRules: null, + }, + metroCity: { id: "city_augsburg", name: "Augsburg" }, + managementLevelGroup: { + id: "mlg_1", + name: "Senior", + targetPercentage: 0.8, + }, + }; + + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn().mockResolvedValue(resourceRecord), + findFirst: vi.fn(), + findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + calculationRule: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + permissions: [PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + }, + ); + + const result = await executeTool( + "get_resource_computation_graph", + JSON.stringify({ + resourceId: "resource_augsburg", + month: "2026-08", + domain: "SAH", + }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + resource: { id: string; displayName: string }; + requestedDomain: string; + totalNodeCount: number; + selectedNodeCount: number; + nodes: Array<{ id: string; domain: string }>; + meta: { + countryCode: string | null; + federalState: string | null; + metroCityName: string | null; + resolvedHolidays: Array<{ name: string; scope: string }>; + }; + }; + + expect(parsed.resource).toEqual({ + id: "resource_augsburg", + eid: "bruce.banner", + displayName: "Bruce Banner", + }); + expect(parsed.requestedDomain).toBe("SAH"); + expect(parsed.totalNodeCount).toBeGreaterThan(parsed.selectedNodeCount); + expect(parsed.selectedNodeCount).toBeGreaterThan(0); + expect(parsed.nodes.every((node) => node.domain === "SAH")).toBe(true); + expect(parsed.meta).toMatchObject({ + countryCode: "DE", + federalState: "BY", + metroCityName: "Augsburg", + }); + expect(parsed.meta.resolvedHolidays).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "Augsburger Friedensfest", + scope: "CITY", + }), + ])); + }); +});