diff --git a/packages/api/src/__tests__/assistant-tools-task-action-assignment-errors.test.ts b/packages/api/src/__tests__/assistant-tools-task-action-assignment-errors.test.ts new file mode 100644 index 0000000..cbed420 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-action-assignment-errors.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-task-action-test-helpers.js"; + +describe("assistant task action tools - assignment errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when an assignment task action target disappears", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "confirm_assignment:asg_missing", + taskStatus: "OPEN", + }), + }, + assignment: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Assignment not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when an assignment is already confirmed", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "confirm_assignment:asg_1", + taskStatus: "OPEN", + }), + }, + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "asg_1", + status: "CONFIRMED", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Assignment is already confirmed.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-action-execution.test.ts b/packages/api/src/__tests__/assistant-tools-task-action-execution.test.ts new file mode 100644 index 0000000..d264262 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-action-execution.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-task-action-test-helpers.js"; + +describe("assistant task action tools - execution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes task action execution through the real notification router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "OPEN", + }), + update: vi.fn().mockResolvedValue({ + id: "task_1", + taskStatus: "DONE", + completedBy: "user_1", + }), + }, + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_1", + status: "PENDING", + }), + update: vi.fn().mockResolvedValue({ + id: "vac_1", + status: "APPROVED", + }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(db.notification.findFirst).toHaveBeenCalledWith({ + where: { + id: "task_1", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: { + id: true, + userId: true, + assigneeId: true, + taskAction: true, + taskStatus: true, + }, + }); + expect(db.vacation.update).toHaveBeenCalledWith({ + where: { id: "vac_1" }, + data: { status: "APPROVED" }, + }); + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "task_1" }, + data: expect.objectContaining({ + taskStatus: "DONE", + completedBy: "user_1", + completedAt: expect.any(Date), + }), + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Vacation approved", + task: expect.objectContaining({ + id: "task_1", + taskStatus: "DONE", + }), + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-action-guards.test.ts b/packages/api/src/__tests__/assistant-tools-task-action-guards.test.ts new file mode 100644 index 0000000..105caaf --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-action-guards.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-task-action-test-helpers.js"; + +describe("assistant task action tools - guards", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when executing a missing task action", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when executing an already completed task action", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "DONE", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task is already completed.", + }); + }); + + it("returns a stable assistant error when executing a dismissed task action", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "DISMISSED", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task has been dismissed and cannot be executed.", + }); + }); + + it("returns a stable assistant error when a task has no executable action", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: null, + taskStatus: "OPEN", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task has no executable action.", + }); + }); + + it("returns a stable assistant error when a task action format is invalid", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "not-a-valid-task-action", + taskStatus: "OPEN", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task action is invalid and cannot be executed.", + }); + }); + + it("returns a stable assistant error when executing a task action without permission", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "OPEN", + }), + }, + }, + { userRole: SystemRole.USER }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "You do not have permission to execute this task action.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-action-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-task-action-test-helpers.ts new file mode 100644 index 0000000..e5d35b7 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-action-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 createAssistantToolContext } from "./assistant-tools-audit-task-test-helpers.js"; + +export const executeTool = executeAssistantTool; +export const createToolContext = createAssistantToolContext; diff --git a/packages/api/src/__tests__/assistant-tools-task-action-vacation-errors.test.ts b/packages/api/src/__tests__/assistant-tools-task-action-vacation-errors.test.ts new file mode 100644 index 0000000..e06f182 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-action-vacation-errors.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { VacationStatus } from "@capakraken/db"; +import { + createToolContext, + executeTool, +} from "./assistant-tools-task-action-test-helpers.js"; + +describe("assistant task action tools - vacation errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when a vacation task action target disappears", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_missing", + taskStatus: "OPEN", + }), + }, + vacation: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a vacation task action is no longer pending", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "OPEN", + }), + }, + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_1", + status: VacationStatus.APPROVED, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation is not pending and cannot be approved or rejected via this task action.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-workflow-assignment.test.ts b/packages/api/src/__tests__/assistant-tools-task-workflow-assignment.test.ts new file mode 100644 index 0000000..48dc5c6 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-workflow-assignment.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, + withUserLookup, +} from "./assistant-tools-task-workflow-test-helpers.js"; + +describe("assistant task workflow tools - assignment", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("assigns a task through the real router path and returns invalidate metadata", async () => { + const db = withUserLookup( + { + notification: { + findUnique: vi.fn().mockResolvedValue({ + id: "task_9", + category: "TASK", + assigneeId: "user_2", + }), + update: vi.fn().mockResolvedValue({ + id: "task_9", + category: "TASK", + assigneeId: "user_4", + }), + }, + }, + "user_mgr", + ); + const ctx = createToolContext(db, SystemRole.MANAGER); + + const result = await executeTool( + "assign_task", + JSON.stringify({ id: "task_9", assigneeId: "user_4" }), + ctx, + ); + + expect(db.notification.findUnique).toHaveBeenCalledWith({ + where: { id: "task_9" }, + }); + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "task_9" }, + data: { assigneeId: "user_4" }, + }); + expect(result.action).toEqual({ type: "invalidate", scope: ["notification"] }); + expect(result.data).toEqual( + expect.objectContaining({ + success: true, + taskId: "task_9", + message: "Assigned task task_9 to user_4.", + task: expect.objectContaining({ + id: "task_9", + assigneeId: "user_4", + }), + }), + ); + }); + + it("returns a stable assistant error when assigning a non-task notification", async () => { + const ctx = createToolContext( + withUserLookup({ + notification: { + findUnique: vi.fn().mockResolvedValue({ + id: "notification_1", + category: "NOTIFICATION", + }), + }, + }), + SystemRole.MANAGER, + ); + + const result = await executeTool( + "assign_task", + JSON.stringify({ id: "notification_1", assigneeId: "user_2" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Only tasks and approvals can be assigned.", + }); + }); + + it("returns a stable assistant error when assigning a task to a missing assignee", async () => { + const ctx = createToolContext( + withUserLookup({ + notification: { + findUnique: vi.fn().mockResolvedValue({ + id: "task_1", + category: "TASK", + }), + update: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_assigneeId_fkey" }, + }), + }, + }), + SystemRole.MANAGER, + ); + + const result = await executeTool( + "assign_task", + JSON.stringify({ id: "task_1", assigneeId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-workflow-status.test.ts b/packages/api/src/__tests__/assistant-tools-task-workflow-status.test.ts new file mode 100644 index 0000000..c473057 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-workflow-status.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, + withUserLookup, +} from "./assistant-tools-task-workflow-test-helpers.js"; + +describe("assistant task workflow tools - status", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps taskId to the router id when updating task status and returns invalidate metadata", async () => { + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: "user_2", + }), + update: vi.fn().mockResolvedValue({ + id: "task_1", + taskStatus: "DONE", + }), + }, + }); + const ctx = createToolContext(db, SystemRole.USER); + + const result = await executeTool( + "update_task_status", + JSON.stringify({ taskId: "task_1", status: "DONE" }), + ctx, + ); + + expect(db.notification.findFirst).toHaveBeenCalledWith({ + where: { + id: "task_1", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + }); + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "task_1" }, + data: { + taskStatus: "DONE", + completedAt: expect.any(Date), + completedBy: "user_1", + }, + }); + expect(result.action).toEqual({ type: "invalidate", scope: ["notification"] }); + expect(result.data).toEqual( + expect.objectContaining({ + success: true, + message: "Task status updated to DONE.", + task: expect.objectContaining({ + id: "task_1", + taskStatus: "DONE", + }), + }), + ); + }); + + it("returns a stable assistant error when updating a missing task", async () => { + const ctx = createToolContext( + withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }), + SystemRole.ADMIN, + ); + + const result = await executeTool( + "update_task_status", + JSON.stringify({ taskId: "task_missing", status: "DONE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task not found with the given criteria.", + }); + }); +});