diff --git a/packages/api/src/__tests__/assistant-tools-allocation-cancel-errors.test.ts b/packages/api/src/__tests__/assistant-tools-allocation-cancel-errors.test.ts new file mode 100644 index 0000000..1df0205 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-cancel-errors.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +import { + createAssignment, + createToolContext, + executeTool, +} from "./assistant-tools-allocation-cancel-test-helpers.js"; + +describe("assistant allocation cancel error tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when allocation cancellation cannot resolve an assignment", async () => { + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + ), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "cancel_allocation", + JSON.stringify({ allocationId: "assignment_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + }); + + it("returns stable assistant errors when allocation cancellation fails during the update step", async () => { + const assignment = createAssignment(); + const errors = [ + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + { + code: "P2025", + message: "Record not found", + meta: { modelName: "Assignment" }, + }, + ] as const; + + for (const error of errors) { + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + update: vi.fn().mockRejectedValue(error), + }, + auditLog: { + create: vi.fn(), + }, + }; + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi + .fn() + .mockImplementation(async (callback: (inner: typeof tx) => Promise) => + callback(tx), + ), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "cancel_allocation", + JSON.stringify({ allocationId: "assignment_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + } + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-allocation-cancel-success.test.ts b/packages/api/src/__tests__/assistant-tools-allocation-cancel-success.test.ts new file mode 100644 index 0000000..48f5d9b --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-cancel-success.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +import { + createAssignment, + createHappyPathTransaction, + createToolContext, + executeTool, +} from "./assistant-tools-allocation-cancel-test-helpers.js"; + +describe("assistant allocation cancel success tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes allocation cancellation through the real allocation router path", async () => { + const { assignmentUpdate, auditCreate, transaction } = createHappyPathTransaction(); + const assignment = createAssignment(); + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 0, + }), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "cancel_allocation", + JSON.stringify({ allocationId: "assignment_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: + "Cancelled allocation: Carol Danvers → Project One (PROJ-1), 2026-06-01 to 2026-06-05", + }), + ); + expect(ctx.db.assignment.findUnique).toHaveBeenCalledWith({ + where: { id: "assignment_1" }, + include: expect.objectContaining({ + resource: expect.any(Object), + project: expect.any(Object), + }), + }); + expect(assignmentUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "assignment_1" }, + data: expect.objectContaining({ + status: "CANCELLED", + }), + }), + ); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-allocation-cancel-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-allocation-cancel-test-helpers.ts new file mode 100644 index 0000000..1803d94 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-cancel-test-helpers.ts @@ -0,0 +1,93 @@ +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-allocation-planning-test-helpers.js"; + +export function createAssignment(overrides: Record = {}) { + return { + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + lcrCents: 7000, + }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + ...overrides, + }; +} + +export function createHappyPathTransaction() { + const assignment = createAssignment(); + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const assignmentUpdate = vi.fn().mockResolvedValue({ + ...assignment, + status: "CANCELLED", + }); + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + update: assignmentUpdate, + }, + auditLog: { + create: auditCreate, + }, + }; + const transaction = vi + .fn() + .mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + + return { assignment, assignmentUpdate, auditCreate, transaction }; +} + +export const executeTool = executeAssistantTool; diff --git a/packages/api/src/__tests__/assistant-tools-allocation-create-errors.test.ts b/packages/api/src/__tests__/assistant-tools-allocation-create-errors.test.ts new file mode 100644 index 0000000..c9aba9c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-create-errors.test.ts @@ -0,0 +1,171 @@ +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-allocation-planning-test-helpers.js"; + +describe("assistant allocation create tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error for duplicate allocations", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: "resource_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }) + .mockResolvedValueOnce({ + id: "resource_1", + eid: "EMP-001", + displayName: "Carol Danvers", + lcrCents: 7000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + project: { + findUnique: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assignment_existing", + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + status: "PROPOSED", + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001" }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + }, + ]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_allocation", + JSON.stringify({ + resourceId: "resource_1", + projectId: "project_1", + startDate: "2026-06-01", + endDate: "2026-06-05", + hoursPerDay: 6, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation already exists for this resource/project/dates. No new allocation created.", + }); + }); + + it("returns a stable assistant error when allocation creation receives an invalid start date", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + chapter: "Delivery", + isActive: true, + fte: 1, + lcrCents: 7000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + project: { + findUnique: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_allocation", + JSON.stringify({ + resourceId: "resource_1", + projectId: "project_1", + startDate: "2026-13-01", + endDate: "2026-06-05", + hoursPerDay: 6, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid startDate: 2026-13-01", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-allocation-create-success.test.ts b/packages/api/src/__tests__/assistant-tools-allocation-create-success.test.ts new file mode 100644 index 0000000..fcab229 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-create-success.test.ts @@ -0,0 +1,211 @@ +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-allocation-planning-test-helpers.js"; + +describe("assistant allocation create tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes allocation creation through the real allocation router path", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const assignmentCreate = vi.fn().mockResolvedValue({ + id: "assignment_1", + demandRequirementId: null, + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + lcrCents: 7000, + }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }); + const tx = { + project: { + findUnique: vi.fn().mockResolvedValue({ id: "project_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "resource_1", + lcrCents: 7000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }, + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + create: assignmentCreate, + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }; + const transaction = vi + .fn() + .mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + lcrCents: 7000, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + budgetCents: 0, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_allocation", + JSON.stringify({ + resourceId: "resource_1", + projectId: "project_1", + startDate: "2026-06-01", + endDate: "2026-06-05", + hoursPerDay: 6, + role: "Designer", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: + "Created allocation: Carol Danvers → Project One (PROJ-1), 6h/day, 2026-06-01 to 2026-06-05", + allocationId: "assignment_1", + status: "PROPOSED", + }), + ); + expect(ctx.db.resource.findUnique).toHaveBeenCalledWith({ + where: { id: "resource_1" }, + select: { + id: true, + displayName: true, + eid: true, + chapter: true, + isActive: true, + }, + }); + expect(ctx.db.project.findUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: expect.objectContaining({ + id: true, + shortCode: true, + name: true, + }), + }); + expect(ctx.db.assignment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + resourceId: "resource_1", + projectId: "project_1", + }, + orderBy: { startDate: "asc" }, + include: expect.objectContaining({ + resource: { + select: expect.objectContaining({ + id: true, + displayName: true, + eid: true, + chapter: true, + lcrCents: true, + }), + }, + project: { + select: expect.objectContaining({ + id: true, + name: true, + shortCode: true, + status: true, + }), + }, + }), + }), + ); + expect(assignmentCreate).toHaveBeenCalledTimes(1); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-allocation-planning-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-allocation-planning-test-helpers.ts new file mode 100644 index 0000000..dceafe0 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-planning-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-allocation-status-errors.test.ts b/packages/api/src/__tests__/assistant-tools-allocation-status-errors.test.ts new file mode 100644 index 0000000..67e5a88 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-status-errors.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +import { + createAssignment, + createToolContext, + executeTool, +} from "./assistant-tools-allocation-status-test-helpers.js"; + +describe("assistant allocation status tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when allocation status update cannot resolve an assignment", async () => { + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + ), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "update_allocation_status", + JSON.stringify({ allocationId: "assignment_missing", newStatus: "ACTIVE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + }); + + it("returns stable assistant errors when allocation status updates fail during the update step", async () => { + const assignment = createAssignment(); + const errors = [ + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + { + code: "P2025", + message: "Record not found", + meta: { modelName: "Assignment" }, + }, + ] as const; + + for (const error of errors) { + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + update: vi.fn().mockRejectedValue(error), + }, + auditLog: { + create: vi.fn(), + }, + }; + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi + .fn() + .mockImplementation(async (callback: (inner: typeof tx) => Promise) => + callback(tx), + ), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "update_allocation_status", + JSON.stringify({ allocationId: "assignment_1", newStatus: "ACTIVE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + } + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-allocation-status-success.test.ts b/packages/api/src/__tests__/assistant-tools-allocation-status-success.test.ts new file mode 100644 index 0000000..f14bc8e --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-status-success.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +import { + createAssignment, + createHappyPathTransaction, + createToolContext, + executeTool, +} from "./assistant-tools-allocation-status-test-helpers.js"; + +describe("assistant allocation status tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes allocation status updates through the real allocation router path", async () => { + const { assignmentUpdate, auditCreate, transaction } = createHappyPathTransaction(); + const assignment = createAssignment(); + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 0, + }), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "update_allocation_status", + JSON.stringify({ allocationId: "assignment_1", newStatus: "ACTIVE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: + "Updated allocation status: Carol Danvers → Project One (PROJ-1), 2026-06-01 to 2026-06-05: PROPOSED → ACTIVE", + })); + expect(ctx.db.assignment.findUnique).toHaveBeenCalledWith({ + where: { id: "assignment_1" }, + include: expect.objectContaining({ + resource: expect.any(Object), + project: expect.any(Object), + }), + }); + expect(assignmentUpdate).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "assignment_1" }, + data: expect.objectContaining({ + status: "ACTIVE", + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-allocation-status-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-allocation-status-test-helpers.ts new file mode 100644 index 0000000..afc44ee --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-allocation-status-test-helpers.ts @@ -0,0 +1,93 @@ +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-allocation-planning-test-helpers.js"; + +export function createAssignment(overrides: Record = {}) { + return { + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + lcrCents: 7000, + }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + ...overrides, + }; +} + +export function createHappyPathTransaction() { + const assignment = createAssignment(); + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const assignmentUpdate = vi.fn().mockResolvedValue({ + ...assignment, + status: "ACTIVE", + }); + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + update: assignmentUpdate, + }, + auditLog: { + create: auditCreate, + }, + }; + const transaction = vi + .fn() + .mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + + return { assignment, assignmentUpdate, auditCreate, transaction }; +} + +export const executeTool = executeAssistantTool;