diff --git a/packages/api/src/__tests__/assistant-tools-project-detail.test.ts b/packages/api/src/__tests__/assistant-tools-project-detail.test.ts new file mode 100644 index 0000000..a2618dd --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-detail.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { createToolContext, executeTool } from "./assistant-tools-project-test-helpers.js"; + +describe("assistant project detail tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes project detail reads through the project router path", async () => { + const db = { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-03-31T00:00:00.000Z"), + }) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + orderType: "CHARGEABLE", + allocationType: "INT", + budgetCents: 500_000, + winProbability: 100, + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-03-31T00:00:00.000Z"), + responsiblePerson: "Bruce Banner", + client: { name: "Acme Mobility" }, + utilizationCategory: { code: "BILL", name: "Billable" }, + _count: { assignments: 3, estimates: 1 }, + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + resource: { displayName: "Bruce Banner", eid: "EMP-001" }, + role: "Lead", + status: "ACTIVE", + hoursPerDay: 8, + startDate: new Date("2026-02-01T00:00:00.000Z"), + endDate: new Date("2026-02-28T00:00:00.000Z"), + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const detailResult = await executeTool( + "get_project", + JSON.stringify({ identifier: "GDM" }), + ctx, + ); + + expect(db.project.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "GDM" }, + select: { + id: true, + shortCode: true, + name: true, + status: true, + startDate: true, + endDate: true, + }, + }); + expect(db.project.findUnique).toHaveBeenNthCalledWith(2, { + where: { shortCode: "GDM" }, + select: { + id: true, + shortCode: true, + name: true, + status: true, + startDate: true, + endDate: true, + }, + }); + expect(db.project.findUnique).toHaveBeenNthCalledWith(3, { + where: { id: "project_1" }, + select: { + id: true, + shortCode: true, + name: true, + status: true, + orderType: true, + allocationType: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + responsiblePerson: true, + client: { select: { name: true } }, + utilizationCategory: { select: { code: true, name: true } }, + _count: { select: { assignments: true, estimates: true } }, + }, + }); + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: { + projectId: "project_1", + status: { not: "CANCELLED" }, + }, + select: { + resource: { select: { displayName: true, eid: true } }, + role: true, + status: true, + hoursPerDay: true, + startDate: true, + endDate: true, + }, + take: 10, + orderBy: { startDate: "desc" }, + }); + expect(JSON.parse(detailResult.content)).toEqual({ + id: "project_1", + code: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + orderType: "CHARGEABLE", + allocationType: "INT", + budget: "5.000,00 EUR", + budgetCents: 500000, + winProbability: "100%", + start: "2026-01-01", + end: "2026-03-31", + responsible: "Bruce Banner", + client: "Acme Mobility", + category: "Billable", + assignmentCount: 3, + estimateCount: 1, + topAllocations: [ + { + resource: "Bruce Banner", + eid: "EMP-001", + role: "Lead", + status: "ACTIVE", + hoursPerDay: 8, + start: "2026-02-01", + end: "2026-02-28", + }, + ], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-read-errors.test.ts b/packages/api/src/__tests__/assistant-tools-project-read-errors.test.ts new file mode 100644 index 0000000..97c6d74 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-read-errors.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-project-test-helpers.js"; + +describe("assistant project read tools - errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a generic assistant error for internal project lookup failures", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "database connection pool exhausted", + }), + ), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "get_project", + JSON.stringify({ identifier: "GDM" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "The tool could not complete due to an internal error.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-search.test.ts b/packages/api/src/__tests__/assistant-tools-project-search.test.ts new file mode 100644 index 0000000..761bbc8 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-search.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { createToolContext, executeTool } from "./assistant-tools-project-test-helpers.js"; + +describe("assistant project search tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes project search through the project router path", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([ + { + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + budgetCents: 500_000, + winProbability: 100, + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-03-31T00:00:00.000Z"), + client: { name: "Acme Mobility" }, + _count: { assignments: 3, estimates: 1 }, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const searchResult = await executeTool( + "search_projects", + JSON.stringify({ query: "Gelddruckmaschine", limit: 10 }), + ctx, + ); + + expect(db.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { name: { contains: "Gelddruckmaschine", mode: "insensitive" } }, + { shortCode: { contains: "Gelddruckmaschine", mode: "insensitive" } }, + ], + }, + select: { + id: true, + shortCode: true, + name: true, + status: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + client: { select: { name: true } }, + _count: { select: { assignments: true, estimates: true } }, + }, + take: 10, + orderBy: { name: "asc" }, + }); + expect(JSON.parse(searchResult.content)).toEqual([ + { + id: "project_1", + code: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + budget: "5.000,00 EUR", + winProbability: "100%", + start: "2026-01-01", + end: "2026-03-31", + client: "Acme Mobility", + assignmentCount: 3, + estimateCount: 1, + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-project-test-helpers.ts new file mode 100644 index 0000000..4a4c5f9 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-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([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: 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, + }; +}