diff --git a/packages/api/src/__tests__/assistant-tools-estimate-read-detail-access.test.ts b/packages/api/src/__tests__/assistant-tools-estimate-read-detail-access.test.ts new file mode 100644 index 0000000..6e17e08 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-estimate-read-detail-access.test.ts @@ -0,0 +1,76 @@ +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(), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { getEstimateById } from "@capakraken/application"; + +import { executeTool } from "../router/assistant-tools.js"; +import { + createToolContext, + resetEstimateToolMocks, +} from "./assistant-tools-estimate-test-helpers.js"; + +describe("assistant estimate detail read tools", () => { + beforeEach(() => { + resetEstimateToolMocks(); + }); + + it("reads estimate details through the real estimate router and rejects plain users", async () => { + vi.mocked(getEstimateById).mockResolvedValue({ + id: "est_1", + name: "North Cluster Estimate", + status: "DRAFT", + versions: [], + } as Awaited>); + + const controllerCtx = createToolContext( + {}, + { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }, + ); + const userCtx = createToolContext({}, { userRole: SystemRole.USER }); + + const successResult = await executeTool( + "get_estimate_detail", + JSON.stringify({ estimateId: "est_1" }), + controllerCtx, + ); + const deniedResult = await executeTool( + "get_estimate_detail", + JSON.stringify({ estimateId: "est_1" }), + userCtx, + ); + + expect(vi.mocked(getEstimateById)).toHaveBeenCalledWith(controllerCtx.db, "est_1"); + expect(JSON.parse(successResult.content)).toEqual( + expect.objectContaining({ + id: "est_1", + name: "North Cluster Estimate", + }), + ); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: "You do not have permission to perform this action.", + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-estimate-read-not-found.test.ts b/packages/api/src/__tests__/assistant-tools-estimate-read-not-found.test.ts new file mode 100644 index 0000000..969e774 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-estimate-read-not-found.test.ts @@ -0,0 +1,97 @@ +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(), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { getEstimateById } from "@capakraken/application"; + +import { executeTool } from "../router/assistant-tools.js"; +import { + createToolContext, + resetEstimateToolMocks, +} from "./assistant-tools-estimate-test-helpers.js"; + +describe("assistant estimate read tools", () => { + beforeEach(() => { + resetEstimateToolMocks(); + }); + + it("returns stable read errors for missing estimates and versions", async () => { + vi.mocked(getEstimateById).mockResolvedValueOnce(null as never); + + const detailCtx = createToolContext( + {}, + { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }, + ); + + const detailResult = await executeTool( + "get_estimate_detail", + JSON.stringify({ estimateId: "missing_estimate" }), + detailCtx, + ); + + expect(JSON.parse(detailResult.content)).toEqual({ + error: "Estimate not found with the given criteria.", + }); + + const versionsCtx = createToolContext( + { + estimate: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const versionsResult = await executeTool( + "list_estimate_versions", + JSON.stringify({ estimateId: "missing_estimate" }), + versionsCtx, + ); + + expect(JSON.parse(versionsResult.content)).toEqual({ + error: "Estimate not found with the given criteria.", + }); + + const snapshotCtx = createToolContext( + { + estimate: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }, + ); + + const snapshotResult = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "missing_estimate", versionId: "missing_version" }), + snapshotCtx, + ); + + expect(JSON.parse(snapshotResult.content)).toEqual({ + error: "Estimate version not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-estimate-read-version-snapshot-access.test.ts b/packages/api/src/__tests__/assistant-tools-estimate-read-version-snapshot-access.test.ts new file mode 100644 index 0000000..f75098b --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-estimate-read-version-snapshot-access.test.ts @@ -0,0 +1,160 @@ +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(), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { + createToolContext, + resetEstimateToolMocks, +} from "./assistant-tools-estimate-test-helpers.js"; + +describe("assistant estimate version snapshot tools", () => { + beforeEach(() => { + resetEstimateToolMocks(); + }); + + it("reads estimate version snapshots through the real estimate router, requires viewCosts, and rejects plain users", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + name: "North Cluster Estimate", + status: "APPROVED", + baseCurrency: "EUR", + versions: [ + { + id: "ver_4", + versionNumber: 4, + label: "Rev 4", + status: "APPROVED", + notes: "Latest", + lockedAt: new Date("2026-03-29T00:00:00.000Z"), + createdAt: new Date("2026-03-28T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + assumptions: [ + { id: "ass_1", category: "DELIVERY", key: "onsite", label: "Onsite support" }, + ], + scopeItems: [ + { id: "scope_1", scopeType: "EPIC", sequenceNo: 1, name: "Pipeline" }, + ], + demandLines: [ + { + id: "dl_1", + name: "Modeling", + chapter: "3D", + hours: 40, + costTotalCents: 400000, + priceTotalCents: 600000, + currency: "EUR", + }, + ], + resourceSnapshots: [ + { + id: "snap_1", + displayName: "Alice", + chapter: "3D", + currency: "EUR", + lcrCents: 10000, + ucrCents: 15000, + }, + ], + exports: [ + { + id: "exp_1", + format: "XLSX", + fileName: "estimate.xlsx", + createdAt: new Date("2026-03-29T10:00:00.000Z"), + }, + ], + }, + ], + }), + }, + }; + const controllerCtx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + const controllerWithoutCostsCtx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [], + }); + const userCtx = createToolContext({}, { userRole: SystemRole.USER }); + + const successResult = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "est_1", versionId: "ver_4" }), + controllerCtx, + ); + const missingPermissionResult = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "est_1", versionId: "ver_4" }), + controllerWithoutCostsCtx, + ); + const deniedResult = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "est_1", versionId: "ver_4" }), + userCtx, + ); + + expect(db.estimate.findUnique).toHaveBeenCalledWith({ + where: { id: "est_1" }, + select: expect.objectContaining({ + id: true, + versions: expect.objectContaining({ + where: { id: "ver_4" }, + }), + }), + }); + expect(JSON.parse(successResult.content)).toEqual( + expect.objectContaining({ + estimate: expect.objectContaining({ + id: "est_1", + baseCurrency: "EUR", + }), + version: expect.objectContaining({ + id: "ver_4", + versionNumber: 4, + }), + totals: expect.objectContaining({ + hours: 40, + costTotalCents: 400000, + priceTotalCents: 600000, + }), + chapterBreakdown: [ + expect.objectContaining({ + chapter: "3D", + lineCount: 1, + }), + ], + }), + ); + expect(JSON.parse(missingPermissionResult.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining(PermissionKey.VIEW_COSTS), + }), + ); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: "You do not have permission to perform this action.", + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-estimate-read-versions-list-access.test.ts b/packages/api/src/__tests__/assistant-tools-estimate-read-versions-list-access.test.ts new file mode 100644 index 0000000..677ec83 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-estimate-read-versions-list-access.test.ts @@ -0,0 +1,102 @@ +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(), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { + createToolContext, + resetEstimateToolMocks, +} from "./assistant-tools-estimate-test-helpers.js"; + +describe("assistant estimate version list tools", () => { + beforeEach(() => { + resetEstimateToolMocks(); + }); + + it("lists estimate versions through the real estimate router and rejects plain users", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + name: "North Cluster Estimate", + status: "DRAFT", + latestVersionNumber: 4, + versions: [ + { + id: "ver_4", + versionNumber: 4, + label: "Rev 4", + status: "IN_REVIEW", + notes: "Latest", + lockedAt: null, + createdAt: new Date("2026-03-28T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + _count: { + assumptions: 2, + scopeItems: 3, + demandLines: 4, + resourceSnapshots: 1, + exports: 0, + }, + }, + ], + }), + }, + }; + const controllerCtx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + const userCtx = createToolContext({}, { userRole: SystemRole.USER }); + + const successResult = await executeTool( + "list_estimate_versions", + JSON.stringify({ estimateId: "est_1" }), + controllerCtx, + ); + const deniedResult = await executeTool( + "list_estimate_versions", + JSON.stringify({ estimateId: "est_1" }), + userCtx, + ); + + expect(db.estimate.findUnique).toHaveBeenCalledWith({ + where: { id: "est_1" }, + select: expect.objectContaining({ + id: true, + versions: expect.any(Object), + }), + }); + expect(JSON.parse(successResult.content)).toEqual( + expect.objectContaining({ + id: "est_1", + latestVersionNumber: 4, + versions: [ + expect.objectContaining({ + id: "ver_4", + versionNumber: 4, + }), + ], + }), + ); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: "You do not have permission to perform this action.", + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-estimate-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-estimate-test-helpers.ts new file mode 100644 index 0000000..e3e3b68 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-estimate-test-helpers.ts @@ -0,0 +1,52 @@ +import { + approveEstimateVersion, + cloneEstimate, + createEstimateExport, + createEstimatePlanningHandoff, + createEstimateRevision, + getEstimateById, + submitEstimateVersion, + updateEstimateDraft, +} from "@capakraken/application"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { vi } from "vitest"; + +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, + }; +} + +export function resetEstimateToolMocks() { + vi.clearAllMocks(); + vi.mocked(approveEstimateVersion).mockReset(); + vi.mocked(cloneEstimate).mockReset(); + vi.mocked(createEstimateExport).mockReset(); + vi.mocked(createEstimatePlanningHandoff).mockReset(); + vi.mocked(createEstimateRevision).mockReset(); + vi.mocked(getEstimateById).mockReset(); + vi.mocked(submitEstimateVersion).mockReset(); + vi.mocked(updateEstimateDraft).mockReset(); +}