From 53158dc60d403843ba854f80fbf3ac9ec050c6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 00:30:23 +0200 Subject: [PATCH] test(api): cover assistant comment tools --- ...stant-tools-comments-create-errors.test.ts | 144 ++++++++++++++++++ ...tant-tools-comments-create-resolve.test.ts | 115 ++++++++++++++ .../assistant-tools-comments-list.test.ts | 86 +++++++++++ ...tant-tools-comments-resolve-errors.test.ts | 66 ++++++++ .../assistant-tools-comments-test-helpers.ts | 65 ++++++++ 5 files changed, 476 insertions(+) create mode 100644 packages/api/src/__tests__/assistant-tools-comments-create-errors.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-comments-create-resolve.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-comments-list.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-comments-resolve-errors.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-comments-test-helpers.ts diff --git a/packages/api/src/__tests__/assistant-tools-comments-create-errors.test.ts b/packages/api/src/__tests__/assistant-tools-comments-create-errors.test.ts new file mode 100644 index 0000000..6ed3849 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-comments-create-errors.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +import { createToolContext, executeTool } from "./assistant-tools-comments-test-helpers.js"; + +describe("assistant comment tools create errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when creating a comment with an empty body", async () => { + const ctx = createToolContext( + { + estimate: { + findUnique: vi.fn(), + }, + comment: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment body is required.", + }); + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when creating a comment with a body that is too long", async () => { + const ctx = createToolContext( + { + estimate: { + findUnique: vi.fn(), + }, + comment: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "x".repeat(10_001), + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment body must be at most 10000 characters.", + }); + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when the comment author disappears during creation", async () => { + const ctx = createToolContext( + { + estimate: { + findUnique: vi.fn().mockResolvedValue({ id: "est_1" }), + }, + comment: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Comment_authorId_fkey" }, + }), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "Please review this estimate.", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment author not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a mentioned user disappears during comment creation", async () => { + const ctx = createToolContext( + { + estimate: { + findUnique: vi.fn().mockResolvedValue({ id: "est_1" }), + }, + comment: { + create: vi.fn().mockResolvedValue({ + id: "comment_created", + body: "Hello @[Peter Parker](user_missing)", + resolved: false, + createdAt: new Date("2026-03-29T11:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + }), + }, + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + auditLog: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "Hello @[Peter Parker](user_missing)", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Mentioned user not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-comments-create-resolve.test.ts b/packages/api/src/__tests__/assistant-tools-comments-create-resolve.test.ts new file mode 100644 index 0000000..cb78077 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-comments-create-resolve.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +import { createToolContext, executeTool } from "./assistant-tools-comments-test-helpers.js"; + +describe("assistant comment mutation tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes comment creation and resolution through the real comment router path", async () => { + const commentFindUnique = vi.fn().mockResolvedValue({ + id: "comment_1", + authorId: "user_1", + entityType: "estimate", + entityId: "est_1", + }); + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ id: "est_1" }), + }, + comment: { + findUnique: commentFindUnique, + create: vi.fn().mockResolvedValue({ + id: "comment_created", + body: "Please review this estimate.", + resolved: false, + createdAt: new Date("2026-03-29T11:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + }), + update: vi.fn().mockResolvedValue({ + id: "comment_1", + body: "Initial note", + resolved: true, + createdAt: new Date("2026-03-29T09:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + }), + }, + notification: { + create: vi.fn(), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const createResult = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "Please review this estimate.", + }), + ctx, + ); + const resolveResult = await executeTool( + "resolve_comment", + JSON.stringify({ commentId: "comment_1", resolved: true }), + ctx, + ); + + expect(db.estimate.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "est_1" }, + select: { id: true }, + }); + expect(db.estimate.findUnique).toHaveBeenNthCalledWith(2, { + where: { id: "est_1" }, + select: { id: true }, + }); + expect(db.comment.create).toHaveBeenCalledWith({ + data: { + entityType: "estimate", + entityId: "est_1", + authorId: "user_1", + body: "Please review this estimate.", + mentions: [], + }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + expect(commentFindUnique).toHaveBeenCalledWith({ + where: { id: "comment_1" }, + select: { id: true, authorId: true, entityType: true, entityId: true }, + }); + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "comment_1" }, + data: { resolved: true }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + expect(JSON.parse(createResult.content)).toEqual({ + id: "comment_created", + author: "Assistant User", + body: "Please review this estimate.", + createdAt: "2026-03-29T11:00:00.000Z", + }); + expect(createResult.action).toEqual({ + type: "invalidate", + scope: ["comment"], + }); + expect(JSON.parse(resolveResult.content)).toEqual({ + id: "comment_1", + resolved: true, + author: "Assistant User", + body: "Initial note", + }); + expect(resolveResult.action).toEqual({ + type: "invalidate", + scope: ["comment"], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-comments-list.test.ts b/packages/api/src/__tests__/assistant-tools-comments-list.test.ts new file mode 100644 index 0000000..9d09c12 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-comments-list.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +import { createToolContext, executeTool } from "./assistant-tools-comments-test-helpers.js"; + +describe("assistant comment list tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes comment listing through the real comment router path", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ id: "est_1" }), + }, + comment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "comment_1", + body: "Initial note", + resolved: false, + createdAt: new Date("2026-03-29T09:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + replies: [ + { + id: "comment_reply_1", + body: "Reply", + resolved: false, + createdAt: new Date("2026-03-29T10:00:00.000Z"), + author: { id: "user_2", name: "Reviewer", email: "reviewer@example.com", image: null }, + }, + ], + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const listResult = await executeTool( + "list_comments", + JSON.stringify({ entityType: "estimate", entityId: "est_1" }), + ctx, + ); + + expect(db.comment.findMany).toHaveBeenCalledWith({ + where: { + entityType: "estimate", + entityId: "est_1", + parentId: null, + }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + replies: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + expect(db.estimate.findUnique).toHaveBeenCalledWith({ + where: { id: "est_1" }, + select: { id: true }, + }); + expect(JSON.parse(listResult.content)).toEqual([ + { + id: "comment_1", + author: "Assistant User", + body: "Initial note", + resolved: false, + createdAt: "2026-03-29T09:00:00.000Z", + replyCount: 1, + replies: [ + { + id: "comment_reply_1", + author: "Reviewer", + body: "Reply", + resolved: false, + createdAt: "2026-03-29T10:00:00.000Z", + }, + ], + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-comments-resolve-errors.test.ts b/packages/api/src/__tests__/assistant-tools-comments-resolve-errors.test.ts new file mode 100644 index 0000000..9849576 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-comments-resolve-errors.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +import { createToolContext, executeTool } from "./assistant-tools-comments-test-helpers.js"; + +describe("assistant comment tools resolve errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when resolving a missing comment", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + comment: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "resolve_comment", + JSON.stringify({ commentId: "comment_missing", resolved: true }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a non-author resolves a comment", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + comment: { + findUnique: vi.fn().mockResolvedValue({ + id: "comment_1", + authorId: "user_2", + entityType: "estimate", + entityId: "est_1", + }), + }, + estimate: { + findUnique: vi.fn().mockResolvedValue({ id: "est_1" }), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "resolve_comment", + JSON.stringify({ commentId: "comment_1", resolved: true }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Only the comment author or an admin can resolve comments.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-comments-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-comments-test-helpers.ts new file mode 100644 index 0000000..81053b9 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-comments-test-helpers.ts @@ -0,0 +1,65 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +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, type ToolContext } from "../router/assistant-tools.js"; + +export const executeTool = executeAssistantTool; + +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, + }; +}