diff --git a/packages/api/src/__tests__/assistant-tools-planning-availability-read.test.ts b/packages/api/src/__tests__/assistant-tools-planning-availability-read.test.ts new file mode 100644 index 0000000..aadfc96 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-planning-availability-read.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { VacationType } from "@capakraken/db"; +import { PermissionKey, 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-planning-read-test-helpers.js"; + +describe("assistant planning availability read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes availability checks through the allocation and vacation router paths", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + fte: 1, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + countryId: null, + federalState: null, + metroCityId: null, + country: null, + metroCity: null, + }), + findFirst: vi.fn(), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assign_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-01T00:00:00.000Z"), + hoursPerDay: 4, + status: "CONFIRMED", + project: { name: "Gelddruckmaschine", shortCode: "GDM" }, + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([ + { + id: "vac_1", + resourceId: "res_1", + status: "APPROVED", + type: VacationType.ANNUAL, + startDate: new Date("2026-04-02T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + isHalfDay: true, + halfDayPart: "AFTERNOON", + resource: { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + lcrCents: 8_000, + }, + requestedBy: null, + approvedBy: null, + }, + ]), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_PLANNING], + }); + + const result = await executeTool( + "check_resource_availability", + JSON.stringify({ resourceId: "res_1", startDate: "2026-04-01", endDate: "2026-04-02" }), + ctx, + ); + + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: { + resourceId: "res_1", + status: { not: "CANCELLED" }, + startDate: { lte: new Date("2026-04-02T00:00:00.000Z") }, + endDate: { gte: new Date("2026-04-01T00:00:00.000Z") }, + }, + select: { + id: true, + startDate: true, + endDate: true, + hoursPerDay: true, + status: true, + project: { select: { name: true, shortCode: true } }, + }, + orderBy: { startDate: "asc" }, + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + resource: "Bruce Banner", + workingDays: 2, + periodAvailableHours: 16, + periodBookedHours: 4, + periodRemainingHours: 12, + availableHoursPerDay: 6, + isFullyAvailable: false, + existingAllocations: [ + { + project: "Gelddruckmaschine (GDM)", + hoursPerDay: 4, + status: "CONFIRMED", + start: "2026-04-01", + end: "2026-04-01", + }, + ], + vacations: [ + { + type: VacationType.ANNUAL, + start: "2026-04-02", + end: "2026-04-02", + isHalfDay: true, + }, + ], + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-planning-rate-read.test.ts b/packages/api/src/__tests__/assistant-tools-planning-rate-read.test.ts new file mode 100644 index 0000000..8ad0c2b --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-planning-rate-read.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, 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-planning-read-test-helpers.js"; + +describe("assistant planning rate read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes rate resolution through the rate-card router path", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + lcrCents: 8_000, + areaRole: { name: "Pipeline TD" }, + managementLevel: { id: "ml_senior" }, + }), + findFirst: vi.fn(), + }, + role: { + findFirst: vi.fn().mockResolvedValue({ id: "role_1" }), + }, + rateCard: { + findMany: vi.fn().mockResolvedValue([ + { + id: "rc_2026", + name: "Standard 2026", + client: null, + lines: [ + { + id: "line_1", + chapter: "Delivery", + seniority: "Senior", + costRateCents: 12_000, + billRateCents: 18_000, + role: { id: "role_1", name: "Pipeline TD" }, + managementLevelId: "ml_senior", + }, + ], + }, + ]), + findUnique: vi.fn(), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + + const result = await executeTool( + "resolve_rate", + JSON.stringify({ resourceId: "res_1" }), + ctx, + ); + + expect(db.resource.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "res_1" }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(2, { + where: { id: "res_1" }, + select: { + id: true, + displayName: true, + chapter: true, + areaRole: { select: { name: true } }, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + rateCard: "Standard 2026", + resource: "Bruce Banner", + rate: "120,00 EUR", + rateCents: 12000, + matchedBy: "role: Pipeline TD", + }); + }); + + it("returns a stable assistant error when rate resolution receives an invalid date", async () => { + const ctx = createToolContext( + { + rateCard: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, + }, + { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }, + ); + + const result = await executeTool( + "resolve_rate", + JSON.stringify({ roleName: "Pipeline TD", date: "2026-99-01" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid date: 2026-99-01", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-planning-read-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-planning-read-test-helpers.ts new file mode 100644 index 0000000..20c735c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-planning-read-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-planning-skill-read.test.ts b/packages/api/src/__tests__/assistant-tools-planning-skill-read.test.ts new file mode 100644 index 0000000..c2d02f3 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-planning-skill-read.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +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-planning-read-test-helpers.js"; + +describe("assistant planning skill read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes skill searches through the resource router path", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + skills: [{ skill: "Houdini FX", proficiency: 4, category: "FX" }], + }, + { + id: "res_2", + eid: "EMP-002", + displayName: "Tony Stark", + chapter: "Tech", + skills: [{ skill: "Nuke", proficiency: 5, category: "Comp" }], + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "search_by_skill", + JSON.stringify({ skill: "houdini" }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + skills: true, + }, + }); + expect(JSON.parse(result.content)).toEqual([ + { + id: "res_1", + eid: "EMP-001", + name: "Bruce Banner", + matchedSkill: "Houdini FX", + level: 4, + chapter: "Delivery", + }, + ]); + }); +});