diff --git a/packages/api/src/__tests__/assistant-tools-project-admin-create-blueprint-errors.test.ts b/packages/api/src/__tests__/assistant-tools-project-admin-create-blueprint-errors.test.ts new file mode 100644 index 0000000..3ca5fb5 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-admin-create-blueprint-errors.test.ts @@ -0,0 +1,177 @@ +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-project-admin-create-test-helpers.js"; + +describe("assistant project admin create tools - blueprint errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when the blueprint cannot be resolved", async () => { + const projectCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + client: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + auditLog: { + create: vi.fn(), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-404", + name: "Missing Blueprint Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Missing Blueprint", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: 'Blueprint not found: "Missing Blueprint"', + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); + + it("returns a generic assistant error when blueprint resolution fails internally during project creation", async () => { + const projectCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "blueprint resolver connection exhausted", + }), + ), + }, + client: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + auditLog: { + create: vi.fn(), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-500", + name: "Blueprint Failure Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Consulting Blueprint", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "The tool could not complete due to an internal error.", + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when the project blueprint disappears before creation", async () => { + const projectCreate = vi.fn(); + const blueprintFindUnique = vi.fn() + .mockResolvedValueOnce({ + id: "bp_1", + name: "Consulting Blueprint", + target: "PROJECT", + isActive: true, + }) + .mockResolvedValueOnce(null); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: blueprintFindUnique, + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-BP", + name: "Blueprint Race Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Consulting Blueprint", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Blueprint not found with the given criteria.", + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-admin-create-success.test.ts b/packages/api/src/__tests__/assistant-tools-project-admin-create-success.test.ts new file mode 100644 index 0000000..581d58f --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-admin-create-success.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-project-admin-create-test-helpers.js"; + +describe("assistant project admin create tools - success", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes project creation through the real project, blueprint, and client router paths", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const projectCreate = vi.fn().mockResolvedValue({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + }); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: vi.fn().mockResolvedValue({ + id: "bp_1", + name: "Consulting Blueprint", + target: "PROJECT", + fieldDefs: [], + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + client: { + findUnique: vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + code: "ACME", + _count: { projects: 0, children: 0 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-1", + name: "Project One", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Consulting Blueprint", + clientName: "ACME", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: expect.stringContaining("Created project: Project One (PROJ-1), budget "), + projectId: "project_1", + }), + ); + expect(projectCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + shortCode: "PROJ-1", + blueprintId: "bp_1", + clientId: "client_1", + responsiblePerson: "Peter Parker", + }), + }), + ); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-admin-create-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-project-admin-create-test-helpers.ts new file mode 100644 index 0000000..cb31c03 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-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-project-admin-create-validation.test.ts b/packages/api/src/__tests__/assistant-tools-project-admin-create-validation.test.ts new file mode 100644 index 0000000..79fb978 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-admin-create-validation.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-project-admin-create-test-helpers.js"; + +describe("assistant project admin create tools - validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when creating a duplicate project short code", async () => { + const projectCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_existing", + shortCode: "PROJ-1", + name: "Existing Project", + }), + create: projectCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-1", + name: "Duplicate Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: 'A project with short code "PROJ-1" already exists.', + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); + + it("requires a responsible person before creating a project", async () => { + const projectCreate = vi.fn(); + const resourceFindFirst = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: resourceFindFirst, + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-NO-RP", + name: "Missing Responsible Person", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "responsiblePerson is required to create a project.", + }); + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(projectCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-admin-delete.test.ts b/packages/api/src/__tests__/assistant-tools-project-admin-delete.test.ts new file mode 100644 index 0000000..f3e6477 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-admin-delete.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +import { + createProject, + createToolContext, + executeTool, +} from "./assistant-tools-project-admin-test-helpers.js"; + +describe("assistant project admin delete tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("enforces admin-only project deletion through the real project router path", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn().mockResolvedValue(createProject({ status: "ACTIVE" })), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "delete_project", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "You do not have permission to perform this action.", + })); + }); + + it("routes project deletion through the real project router path for admins", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const tx = { + assignment: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + demandRequirement: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + calculationRule: { updateMany: vi.fn().mockResolvedValue({ count: 0 }) }, + project: { delete: vi.fn().mockResolvedValue({ id: "project_1" }) }, + auditLog: { create: auditCreate }, + }; + const transaction = vi + .fn() + .mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const projectFindUnique = vi.fn() + .mockResolvedValueOnce(createProject({ status: "ACTIVE" })) + .mockResolvedValueOnce(createProject({ name: "Project One" })); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "delete_project", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Deleted project: Project One (PROJ-1)", + })); + expect(transaction).toHaveBeenCalledTimes(1); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("returns a stable error when a project disappears before deletion completes", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(createProject({ status: "ACTIVE" })) + .mockResolvedValueOnce(createProject({ name: "Project One" })), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Project not found" }), + ), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "delete_project", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Project not found: project_1", + })); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-project-admin-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-project-admin-test-helpers.ts new file mode 100644 index 0000000..5769889 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-admin-test-helpers.ts @@ -0,0 +1,53 @@ +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"; + +export { createToolContext } from "./assistant-tools-admin-crud-test-helpers.js"; + +export function createProject( + overrides: Record = {}, +): Record { + return { + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + responsiblePerson: "Peter Parker", + ...overrides, + }; +} + +export const executeTool = executeAssistantTool; diff --git a/packages/api/src/__tests__/assistant-tools-project-admin-update.test.ts b/packages/api/src/__tests__/assistant-tools-project-admin-update.test.ts new file mode 100644 index 0000000..23eb7b5 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-project-admin-update.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +import { + createProject, + createToolContext, + executeTool, +} from "./assistant-tools-project-admin-test-helpers.js"; + +describe("assistant project admin update tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes project updates through the real project router path and resolves short codes before updating", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const projectFindUnique = vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createProject()) + .mockResolvedValueOnce(createProject({ dynamicFields: {}, blueprintId: null })); + const projectUpdate = vi.fn().mockResolvedValue({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One Reloaded", + }); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + findFirst: vi.fn().mockResolvedValue(null), + update: projectUpdate, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "update_project", + JSON.stringify({ + id: "PROJ-1", + name: "Project One Reloaded", + responsiblePerson: "Peter Parker", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Updated project Project One Reloaded (PROJ-1)", + updatedFields: ["name", "responsiblePerson"], + })); + expect(projectUpdate).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "project_1" }, + data: expect.objectContaining({ + name: "Project One Reloaded", + responsiblePerson: "Peter Parker", + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("returns a stable assistant error when the project disappears during update", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createProject()) + .mockResolvedValueOnce(createProject({ dynamicFields: {}, blueprintId: null })), + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record to update not found.", + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "update_project", + JSON.stringify({ id: "PROJ-1", name: "Project One Reloaded" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Project not found with the given criteria.", + }); + }); +});