diff --git a/packages/api/src/__tests__/assistant-tools-admin-crud-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-admin-crud-test-helpers.ts new file mode 100644 index 0000000..dceafe0 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-admin-crud-test-helpers.ts @@ -0,0 +1,29 @@ +import { PermissionKey, SystemRole } 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-resource-admin-create-persistence-errors.test.ts b/packages/api/src/__tests__/assistant-tools-resource-admin-create-persistence-errors.test.ts new file mode 100644 index 0000000..64edff3 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-admin-create-persistence-errors.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-resource-admin-create-test-helpers.js"; + +describe("assistant resource admin create tools - persistence errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a generic assistant error when role resolution fails internally during resource creation", async () => { + const resourceCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "role resolver connection exhausted", + }), + ), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-500", + displayName: "Role Failure", + email: "role-failure@example.com", + lcrCents: 8000, + roleName: "Designer", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "The tool could not complete due to an internal error.", + }); + expect(resourceCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when the selected resource role disappears before creation", async () => { + const resourceCreate = vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Resource_roleId_fkey" }, + }); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockResolvedValue({ id: "role_1", name: "Designer" }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-002", + displayName: "Carol Danvers", + email: "carol@example.com", + lcrCents: 8000, + roleName: "Designer", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Role not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-resource-admin-create-success.test.ts b/packages/api/src/__tests__/assistant-tools-resource-admin-create-success.test.ts new file mode 100644 index 0000000..485c566 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-admin-create-success.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-resource-admin-create-test-helpers.js"; + +describe("assistant resource admin create tools - success", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes resource creation through the real resource router path and writes an audit log", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const resourceFindFirst = vi.fn().mockResolvedValue(null); + const resourceCreate = vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + email: "carol@example.com", + resourceRoles: [], + }); + const ctx = createToolContext( + { + resource: { + findFirst: resourceFindFirst, + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockResolvedValue({ + id: "role_1", + name: "Designer", + _count: { resourceRoles: 0 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + country: { + findUnique: vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Germany", + metroCities: [], + _count: { resources: 0 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + orgUnit: { + findUnique: vi.fn().mockResolvedValue({ + id: "ou_1", + name: "Delivery", + shortName: "DEL", + _count: { resources: 0 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-001", + displayName: "Carol Danvers", + email: "carol@example.com", + lcrCents: 8000, + roleName: "Designer", + countryCode: "DE", + orgUnitName: "Delivery", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Created resource: Carol Danvers (EMP-001)", + }), + ); + expect(resourceFindFirst).toHaveBeenCalled(); + expect(resourceCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + roleId: "role_1", + countryId: "country_de", + orgUnitId: "ou_1", + }), + }), + ); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-resource-admin-create-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-resource-admin-create-test-helpers.ts new file mode 100644 index 0000000..cb31c03 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-admin-create-test-helpers.ts @@ -0,0 +1,40 @@ +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 } from "../router/assistant-tools.js"; +import { createToolContext as createAdminCrudToolContext } from "./assistant-tools-admin-crud-test-helpers.js"; + +export const executeTool = executeAssistantTool; +export const createToolContext = createAdminCrudToolContext; diff --git a/packages/api/src/__tests__/assistant-tools-resource-admin-create-validation-errors.test.ts b/packages/api/src/__tests__/assistant-tools-resource-admin-create-validation-errors.test.ts new file mode 100644 index 0000000..978170c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-admin-create-validation-errors.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-resource-admin-create-test-helpers.js"; + +describe("assistant resource admin create tools - validation errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when the role cannot be resolved during resource creation", async () => { + const resourceCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-404", + displayName: "Role Missing", + email: "missing@example.com", + lcrCents: 8000, + roleName: "Missing Role", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: 'Role not found: "Missing Role"', + }); + expect(resourceCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when creating a duplicate resource", async () => { + const resourceCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + id: "resource_existing", + eid: "EMP-001", + email: "carol@example.com", + }), + create: resourceCreate, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-001", + displayName: "Carol Danvers", + email: "carol@example.com", + lcrCents: 8000, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "A resource with this EID or email already exists.", + }); + expect(resourceCreate).not.toHaveBeenCalled(); + }); + + it("requires an email address before creating a resource", async () => { + const resourceCreate = vi.fn(); + const roleFindUnique = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: roleFindUnique, + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-NO-MAIL", + displayName: "Missing Email", + lcrCents: 8000, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "email is required to create a resource.", + }); + expect(roleFindUnique).not.toHaveBeenCalled(); + expect(resourceCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-resource-admin-update-deactivate.test.ts b/packages/api/src/__tests__/assistant-tools-resource-admin-update-deactivate.test.ts new file mode 100644 index 0000000..d54cfcd --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-resource-admin-update-deactivate.test.ts @@ -0,0 +1,175 @@ +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-admin-crud-test-helpers.js"; + +describe("assistant resource admin update and deactivate tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes resource updates through the real resource router path and writes an audit log", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const resourceFindUnique = vi.fn() + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + dynamicFields: {}, + blueprintId: null, + }); + const resourceUpdate = vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Captain Marvel", + resourceRoles: [], + }); + const ctx = createToolContext( + { + resource: { + findUnique: resourceFindUnique, + update: resourceUpdate, + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "update_resource", + JSON.stringify({ + id: "res_1", + displayName: "Captain Marvel", + chapter: "Delivery", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Updated resource Captain Marvel (EMP-001)", + updatedFields: ["displayName", "chapter"], + }), + ); + expect(resourceUpdate).toHaveBeenCalled(); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("returns a stable assistant error when the resource disappears during update", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + dynamicFields: {}, + blueprintId: null, + }), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record to update not found.", + }), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "update_resource", + JSON.stringify({ id: "res_1", displayName: "Captain Marvel" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when the resource disappears during deactivation", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record to update not found.", + }), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "deactivate_resource", + JSON.stringify({ identifier: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource not found with the given criteria.", + }); + }); +});