diff --git a/packages/api/src/__tests__/assistant-tools-resource-availability.test.ts b/packages/api/src/__tests__/assistant-tools-resource-availability.test.ts new file mode 100644 index 0000000..9422e6e --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-availability.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-holiday-capacity-test-helpers.js"; + +describe("assistant resource availability tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("checks resource availability with regional holidays excluded from capacity", async () => { + const resourceRecord = { + id: "res_1", + displayName: "Bruce Banner", + eid: "bruce.banner", + fte: 1, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 }, + countryId: "country_de", + federalState: "BY", + metroCityId: null, + country: { code: "DE", dailyWorkingHours: 8 }, + metroCity: null, + }; + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(resourceRecord), + findFirst: vi.fn(), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + hoursPerDay: 8, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + status: "CONFIRMED", + project: { name: "Gamma", shortCode: "GAM" }, + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(db, [PermissionKey.VIEW_PLANNING]); + + const result = await executeTool( + "check_resource_availability", + JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + workingDays: number; + periodAvailableHours: number; + periodBookedHours: number; + periodRemainingHours: number; + availableHoursPerDay: number; + isFullyAvailable: boolean; + }; + + expect(parsed.workingDays).toBe(1); + expect(parsed.periodAvailableHours).toBe(8); + expect(parsed.periodBookedHours).toBe(8); + expect(parsed.periodRemainingHours).toBe(0); + expect(parsed.availableHoursPerDay).toBe(0); + expect(parsed.isFullyAvailable).toBe(false); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-resource-detail.test.ts b/packages/api/src/__tests__/assistant-tools-resource-detail.test.ts new file mode 100644 index 0000000..3e015fd --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-detail.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { createToolContext, executeTool } from "./assistant-tools-resource-test-helpers.js"; + +describe("assistant resource detail tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes resource detail reads through the resource router path", async () => { + const db = { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + isActive: true, + }) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + email: "bruce@example.com", + chapter: "Delivery", + fte: 1, + lcrCents: 8_500, + ucrCents: 10_500, + chargeabilityTarget: 80, + isActive: true, + availability: { monday: 8 }, + skills: [{ name: "Houdini", level: 5 }], + postalCode: "80331", + federalState: "BY", + areaRole: { name: "Pipeline TD", color: "#112233" }, + country: { code: "DE", name: "Germany", dailyWorkingHours: 8 }, + metroCity: { name: "Munich" }, + managementLevelGroup: { name: "Senior", targetPercentage: 75 }, + orgUnit: { name: "Operations", level: 5 }, + _count: { assignments: 4, vacations: 2 }, + }), + findFirst: vi.fn(), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const detailResult = await executeTool( + "get_resource", + JSON.stringify({ identifier: "EMP-001" }), + ctx, + ); + + expect(db.resource.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "EMP-001" }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(2, { + where: { eid: "EMP-001" }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(3, { + where: { id: "res_1" }, + select: { + id: true, + eid: true, + displayName: true, + email: true, + chapter: true, + fte: true, + lcrCents: true, + ucrCents: true, + chargeabilityTarget: true, + isActive: true, + availability: true, + skills: true, + postalCode: true, + federalState: true, + areaRole: { select: { name: true, color: true } }, + country: { select: { code: true, name: true, dailyWorkingHours: true } }, + metroCity: { select: { name: true } }, + managementLevelGroup: { select: { name: true, targetPercentage: true } }, + orgUnit: { select: { name: true, level: true } }, + _count: { select: { assignments: true, vacations: true } }, + }, + }); + expect(JSON.parse(detailResult.content)).toEqual({ + id: "res_1", + eid: "EMP-001", + name: "Bruce Banner", + email: "bruce@example.com", + chapter: "Delivery", + role: "Pipeline TD", + country: "Germany", + countryCode: "DE", + countryHours: 8, + metroCity: "Munich", + fte: 1, + lcr: "85,00 EUR", + ucr: "105,00 EUR", + chargeabilityTarget: "80%", + managementLevel: "Senior", + orgUnit: "Operations", + postalCode: "80331", + federalState: "BY", + active: true, + totalAssignments: 4, + totalVacations: 2, + skillCount: 1, + topSkills: ["Houdini (5)"], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-resource-search.test.ts b/packages/api/src/__tests__/assistant-tools-resource-search.test.ts new file mode 100644 index 0000000..abe4d4a --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-search.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { createToolContext, executeTool } from "./assistant-tools-resource-test-helpers.js"; + +describe("assistant resource search tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes resource search through the resource router path", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + fte: 1, + lcrCents: 8_500, + chargeabilityTarget: 80, + isActive: true, + areaRole: { name: "Pipeline TD" }, + country: { code: "DE", name: "Germany", dailyWorkingHours: 8 }, + metroCity: { name: "Munich" }, + orgUnit: { name: "Operations", level: 5 }, + }, + ]), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_ALL_RESOURCES], + }); + + const searchResult = await executeTool( + "search_resources", + JSON.stringify({ query: "Bruce", country: "DE", roleName: "Pipeline", limit: 5 }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + OR: [ + { displayName: { contains: "Bruce", mode: "insensitive" } }, + { eid: { contains: "Bruce", mode: "insensitive" } }, + { chapter: { contains: "Bruce", mode: "insensitive" } }, + ], + country: { + OR: [ + { code: { equals: "DE", mode: "insensitive" } }, + { name: { contains: "DE", mode: "insensitive" } }, + ], + }, + areaRole: { name: { contains: "Pipeline", mode: "insensitive" } }, + }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + fte: true, + lcrCents: true, + chargeabilityTarget: true, + isActive: true, + areaRole: { select: { name: true } }, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + orgUnit: { select: { name: true } }, + }, + take: 5, + orderBy: { displayName: "asc" }, + }); + expect(JSON.parse(searchResult.content)).toEqual([ + { + id: "res_1", + eid: "EMP-001", + name: "Bruce Banner", + chapter: "Delivery", + role: "Pipeline TD", + country: "Germany", + countryCode: "DE", + metroCity: "Munich", + orgUnit: "Operations", + fte: 1, + lcr: "85,00 EUR", + chargeabilityTarget: "80%", + active: true, + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-resource-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-resource-test-helpers.ts new file mode 100644 index 0000000..81053b9 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-test-helpers.ts @@ -0,0 +1,65 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { 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 as executeAssistantTool, type ToolContext } from "../router/assistant-tools.js"; + +export const executeTool = executeAssistantTool; + +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, + }; +}