diff --git a/packages/api/src/__tests__/assistant-tools-audit-task-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-audit-task-test-helpers.ts new file mode 100644 index 0000000..9341982 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-audit-task-test-helpers.ts @@ -0,0 +1,37 @@ +import { SystemRole } from "@capakraken/shared"; +import type { ToolContext } from "../router/assistant-tools.js"; + +export function createToolContext( + db: Record, + options?: { + userRole?: SystemRole; + }, +): ToolContext { + const userRole = options?.userRole ?? SystemRole.ADMIN; + let effectiveDb: Record; + effectiveDb = "$transaction" in db + ? db + : { + ...db, + $transaction: async ( + callback: (tx: ToolContext["db"]) => Promise, + ) => callback(effectiveDb as ToolContext["db"]), + }; + + return { + db: effectiveDb as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(), + 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-task-counts.test.ts b/packages/api/src/__tests__/assistant-tools-task-counts.test.ts new file mode 100644 index 0000000..d4e993c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-counts.test.ts @@ -0,0 +1,52 @@ +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 counts tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns task counts for the current user through the real router path", async () => { + const db = withUserLookup({ + notification: { + groupBy: vi.fn().mockResolvedValue([ + { taskStatus: "OPEN", _count: 4 }, + { taskStatus: "DONE", _count: 2 }, + ]), + count: vi.fn().mockResolvedValue(3), + }, + }); + const ctx = createToolContext(db, SystemRole.USER); + + const result = await executeTool("get_task_counts", JSON.stringify({}), ctx); + + expect(db.notification.groupBy).toHaveBeenCalledWith({ + by: ["taskStatus"], + where: { + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + _count: true, + }); + expect(db.notification.count).toHaveBeenCalledWith({ + where: { + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + taskStatus: { in: ["OPEN", "IN_PROGRESS"] }, + dueDate: { lt: expect.any(Date) }, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + open: 4, + inProgress: 0, + done: 2, + dismissed: 0, + overdue: 3, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-create-errors.test.ts b/packages/api/src/__tests__/assistant-tools-task-create-errors.test.ts new file mode 100644 index 0000000..aef1277 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-create-errors.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { 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-notification-test-helpers.js"; + +describe("assistant task creation tools errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when task dueDate is invalid", async () => { + const ctx = createToolContext({}, SystemRole.MANAGER); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_2", + title: "Follow up", + dueDate: "not-a-datetime", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid dueDate: not-a-datetime", + }); + }); + + it("returns a stable assistant error when task recipient user is missing", async () => { + const ctx = createToolContext( + { + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + }, + SystemRole.MANAGER, + ); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_missing", + title: "Follow up", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task recipient user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when task sender user is missing", async () => { + const ctx = createToolContext( + { + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_senderId_fkey" }, + }), + }, + }, + SystemRole.MANAGER, + ); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_2", + title: "Follow up", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Sender user not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-create-success.test.ts b/packages/api/src/__tests__/assistant-tools-task-create-success.test.ts new file mode 100644 index 0000000..c71912d --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-create-success.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { 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-notification-test-helpers.js"; + +describe("assistant task creation tools success", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a task for a user through the notification router", async () => { + const db = { + notification: { + create: vi.fn().mockResolvedValue({ id: "task_2", userId: "user_2" }), + findUnique: vi.fn().mockResolvedValue({ + id: "task_2", + title: "Follow up", + category: "TASK", + taskStatus: "OPEN", + }), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_2", email: "user2@example.com", name: "User Two" }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_2", + title: "Follow up", + dueDate: "2026-04-03T11:00:00.000Z", + channel: "in_app", + }), + ctx, + ); + + expect(db.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user_2", + type: "TASK_CREATED", + category: "TASK", + taskStatus: "OPEN", + title: "Follow up", + dueDate: new Date("2026-04-03T11:00:00.000Z"), + senderId: "user_1", + channel: "in_app", + }), + }); + expect(db.notification.findUnique).toHaveBeenCalledWith({ + where: { id: "task_2" }, + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + taskId: "task_2", + message: 'Created task "Follow up" for user_2.', + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-detail.test.ts b/packages/api/src/__tests__/assistant-tools-task-detail.test.ts new file mode 100644 index 0000000..f461949 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-detail.test.ts @@ -0,0 +1,75 @@ +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 detail tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps taskId for get_task_detail through the real router path", async () => { + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_7", + title: "Review timeline", + category: "TASK", + userId: "user_1", + assigneeId: null, + sender: { id: "user_mgr", name: "Manager", email: "mgr@example.com" }, + }), + }, + }); + const ctx = createToolContext(db, SystemRole.USER); + + const result = await executeTool( + "get_task_detail", + JSON.stringify({ taskId: "task_7" }), + ctx, + ); + + expect(db.notification.findFirst).toHaveBeenCalledWith({ + where: { + id: "task_7", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: expect.objectContaining({ + id: true, + title: true, + sender: { select: { id: true, name: true, email: true } }, + }), + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + id: "task_7", + title: "Review timeline", + }), + ); + }); + + it("returns a stable assistant error for a missing task detail", async () => { + const ctx = createToolContext( + withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }), + SystemRole.ADMIN, + ); + + const result = await executeTool( + "get_task_detail", + JSON.stringify({ taskId: "task_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-read.test.ts b/packages/api/src/__tests__/assistant-tools-task-read.test.ts new file mode 100644 index 0000000..1476c9d --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-read.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { 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-audit-task-test-helpers.js"; + +describe("assistant task read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns task lists and counts for the current user through the notification router", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findMany: vi.fn().mockResolvedValue([ + { + id: "task_1", + title: "Approve vacation", + category: "APPROVAL", + taskStatus: "OPEN", + priority: "HIGH", + }, + ]), + groupBy: vi.fn().mockResolvedValue([ + { taskStatus: "OPEN", _count: 2 }, + { taskStatus: "IN_PROGRESS", _count: 1 }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const listResult = await executeTool( + "list_tasks", + JSON.stringify({ status: "OPEN", includeAssigned: false, limit: 10 }), + ctx, + ); + const countsResult = await executeTool("get_task_counts", "{}", ctx); + + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { + userId: "user_1", + category: { in: ["TASK", "APPROVAL"] }, + taskStatus: "OPEN", + }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], + take: 10, + }); + expect(JSON.parse(listResult.content)).toEqual([ + expect.objectContaining({ + id: "task_1", + title: "Approve vacation", + }), + ]); + expect(JSON.parse(countsResult.content)).toEqual({ + open: 2, + inProgress: 1, + done: 0, + dismissed: 0, + overdue: 1, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-task-workflow-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-task-workflow-test-helpers.ts new file mode 100644 index 0000000..3f43fd4 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-task-workflow-test-helpers.ts @@ -0,0 +1,17 @@ +import { vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool as executeAssistantTool } from "../router/assistant-tools.js"; + +export { createToolContext, withUserLookup } from "./assistant-tools-notification-test-helpers.js"; + +export const executeTool = executeAssistantTool;