diff --git a/packages/api/src/__tests__/assistant-tools-master-data-blueprints-rate-cards.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-blueprints-rate-cards.test.ts new file mode 100644 index 0000000..0cb6df1 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-blueprints-rate-cards.test.ts @@ -0,0 +1,145 @@ +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, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { countPlanningEntries } from "@capakraken/application"; +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js"; + +describe("assistant master data blueprint and rate-card read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + }); + + it("routes blueprint and rate card reads through their backing routers", async () => { + const db = { + blueprint: { + findMany: vi.fn().mockResolvedValue([ + { + id: "bp_project", + name: "Project Default", + _count: { projects: 3 }, + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: "bp_project", + name: "Project Default", + fieldDefs: [{ key: "market", type: "text" }], + rolePresets: [{ role: "Consulting", share: 0.5 }], + }), + }, + rateCard: { + findMany: vi.fn().mockResolvedValue([ + { + id: "rc_2026", + name: "Standard 2026", + effectiveFrom: new Date("2026-01-01T00:00:00.000Z"), + effectiveTo: null, + _count: { lines: 12 }, + client: null, + }, + ]), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS], + }); + + const blueprintsResult = await executeTool("list_blueprints", "{}", ctx); + const blueprintResult = await executeTool( + "get_blueprint", + JSON.stringify({ identifier: "bp_project" }), + ctx, + ); + const rateCardsResult = await executeTool( + "list_rate_cards", + JSON.stringify({ query: "Standard", limit: 10 }), + ctx, + ); + + expect(db.blueprint.findMany).toHaveBeenCalledWith({ + select: { + id: true, + name: true, + _count: { select: { projects: true } }, + }, + orderBy: { name: "asc" }, + }); + expect(db.blueprint.findUnique).toHaveBeenCalledWith({ + where: { id: "bp_project" }, + }); + expect(db.rateCard.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + name: { contains: "Standard", mode: "insensitive" }, + }, + include: { + _count: { select: { lines: true } }, + client: { select: { id: true, name: true, code: true } }, + }, + orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], + }); + + expect(JSON.parse(blueprintsResult.content)).toEqual([ + { + id: "bp_project", + name: "Project Default", + projectCount: 3, + }, + ]); + expect(JSON.parse(blueprintResult.content)).toEqual( + expect.objectContaining({ + id: "bp_project", + name: "Project Default", + fieldDefs: [{ key: "market", type: "text" }], + rolePresets: [{ role: "Consulting", share: 0.5 }], + }), + ); + expect(JSON.parse(rateCardsResult.content)).toEqual([ + { + id: "rc_2026", + name: "Standard 2026", + effectiveFrom: "2026-01-01", + effectiveTo: null, + lineCount: 12, + }, + ]); + }); + + it("returns a stable assistant error when a blueprint cannot be resolved for read access", async () => { + const ctx = createToolContext( + { + blueprint: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.VIEW_PLANNING], + }, + ); + + const result = await executeTool( + "get_blueprint", + JSON.stringify({ identifier: "Missing Blueprint" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Blueprint not found: Missing Blueprint", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-master-data-calculation-rules-read.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-calculation-rules-read.test.ts new file mode 100644 index 0000000..a5013c2 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-calculation-rules-read.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { countPlanningEntries } from "@capakraken/application"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js"; + +describe("assistant master data calculation rule read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + }); + + it("routes calculation rule reads through their backing router", async () => { + const db = { + calculationRule: { + findMany: vi.fn().mockResolvedValue([ + { + id: "calc_1", + name: "Rush Fee", + description: "Adds cost for rush work", + isActive: true, + triggerType: "RUSH", + orderType: null, + costEffect: "INCREASE", + costReductionPercent: null, + chargeabilityEffect: "NONE", + priority: 90, + project: { id: "proj_1", name: "Falcon", shortCode: "FAL" }, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const calculationRulesResult = await executeTool("list_calculation_rules", "{}", ctx); + + expect(db.calculationRule.findMany).toHaveBeenCalledWith({ + orderBy: [{ priority: "desc" }, { name: "asc" }], + include: { + project: { + select: { + id: true, + shortCode: true, + name: true, + status: true, + endDate: true, + }, + }, + }, + }); + + expect(JSON.parse(calculationRulesResult.content)).toEqual([ + expect.objectContaining({ + id: "calc_1", + name: "Rush Fee", + project: expect.objectContaining({ + id: "proj_1", + shortCode: "FAL", + }), + }), + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-master-data-clients-read.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-clients-read.test.ts new file mode 100644 index 0000000..586cf79 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-clients-read.test.ts @@ -0,0 +1,75 @@ +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, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { countPlanningEntries } from "@capakraken/application"; +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js"; + +describe("assistant master data clients read tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + }); + + it("routes client reads through their backing router", async () => { + const db = { + client: { + findMany: vi.fn().mockResolvedValue([ + { + id: "client_1", + name: "Acme Mobility", + code: "ACM", + parentId: null, + isActive: true, + sortOrder: 1, + tags: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + _count: { children: 0, projects: 4 }, + }, + ]), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_PLANNING], + }); + + const clientsResult = await executeTool( + "list_clients", + JSON.stringify({ query: "ACM", limit: 5 }), + ctx, + ); + + expect(db.client.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + OR: [ + { name: { contains: "ACM", mode: "insensitive" } }, + { code: { contains: "ACM", mode: "insensitive" } }, + ], + }, + include: { _count: { select: { children: true, projects: true } } }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + expect(JSON.parse(clientsResult.content)).toEqual([ + { + id: "client_1", + name: "Acme Mobility", + code: "ACM", + projectCount: 4, + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-master-data-effort-experience-read.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-effort-experience-read.test.ts new file mode 100644 index 0000000..b7a62b9 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-effort-experience-read.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { countPlanningEntries } from "@capakraken/application"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js"; + +describe("assistant master data effort and experience read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + }); + + it("routes effort rule and experience multiplier reads through their backing routers", async () => { + const db = { + effortRuleSet: { + findMany: vi.fn().mockResolvedValue([ + { + id: "ers_default", + name: "Default Effort", + isDefault: true, + rules: [ + { + id: "eff_1", + description: "Animation per shot", + scopeType: "SHOT", + discipline: "Animation", + chapter: "3D", + unitMode: "per_item", + hoursPerUnit: 12, + sortOrder: 0, + }, + ], + }, + ]), + }, + experienceMultiplierSet: { + findMany: vi.fn().mockResolvedValue([ + { + id: "ems_default", + name: "Default Multipliers", + isDefault: true, + rules: [ + { + id: "exp_1", + description: "Senior DE uplift", + chapter: "3D", + location: "DE", + level: "Senior", + costMultiplier: 1.2, + billMultiplier: 1.15, + shoringRatio: 0.4, + additionalEffortRatio: 0.1, + sortOrder: 0, + }, + ], + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const effortRulesResult = await executeTool("list_effort_rules", "{}", ctx); + const experienceMultipliersResult = await executeTool("list_experience_multipliers", "{}", ctx); + + expect(db.effortRuleSet.findMany).toHaveBeenCalledWith({ + include: { + rules: { orderBy: { sortOrder: "asc" } }, + }, + orderBy: [{ isDefault: "desc" }, { name: "asc" }], + }); + expect(db.experienceMultiplierSet.findMany).toHaveBeenCalledWith({ + include: { + rules: { orderBy: { sortOrder: "asc" } }, + }, + orderBy: [{ isDefault: "desc" }, { name: "asc" }], + }); + + expect(JSON.parse(effortRulesResult.content)).toEqual([ + { + id: "eff_1", + description: "Animation per shot", + scopeType: "SHOT", + discipline: "Animation", + chapter: "3D", + unitMode: "per_item", + hoursPerUnit: 12, + sortOrder: 0, + ruleSet: { name: "Default Effort", isDefault: true }, + }, + ]); + expect(JSON.parse(experienceMultipliersResult.content)).toEqual([ + { + id: "exp_1", + description: "Senior DE uplift", + chapter: "3D", + location: "DE", + level: "Senior", + costMultiplier: 1.2, + billMultiplier: 1.15, + shoringRatio: 0.4, + additionalEffortRatio: 0.1, + sortOrder: 0, + multiplierSet: { name: "Default Multipliers", isDefault: true }, + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-master-data-management-utilization-read.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-management-utilization-read.test.ts new file mode 100644 index 0000000..547b027 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-management-utilization-read.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { countPlanningEntries } from "@capakraken/application"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js"; + +describe("assistant master data management and utilization read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + }); + + it("routes management level and utilization category reads through their backing routers", async () => { + const db = { + managementLevelGroup: { + findMany: vi.fn().mockResolvedValue([ + { + id: "mlg_exec", + name: "Executive", + targetPercentage: 75, + levels: [{ id: "ml_partner", name: "Partner" }], + }, + ]), + }, + utilizationCategory: { + findMany: vi.fn().mockResolvedValue([ + { + id: "util_billable", + code: "BILLABLE", + name: "Billable", + description: "Client work", + isActive: true, + sortOrder: 1, + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: "util_billable", + code: "BILLABLE", + name: "Billable", + description: "Client work", + isActive: true, + sortOrder: 1, + _count: { projects: 3 }, + }), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_PLANNING], + }); + + const managementLevelsResult = await executeTool("list_management_levels", "{}", ctx); + const utilizationCategoriesResult = await executeTool("list_utilization_categories", "{}", ctx); + + expect(db.managementLevelGroup.findMany).toHaveBeenCalledWith({ + include: { levels: { orderBy: { name: "asc" } } }, + orderBy: { sortOrder: "asc" }, + }); + expect(db.utilizationCategory.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { sortOrder: "asc" }, + }); + expect(db.utilizationCategory.findUnique).toHaveBeenCalledWith({ + where: { id: "util_billable" }, + include: { _count: { select: { projects: true } } }, + }); + + expect(JSON.parse(managementLevelsResult.content)).toEqual([ + { + id: "mlg_exec", + name: "Executive", + target: "75%", + levels: [{ id: "ml_partner", name: "Partner" }], + }, + ]); + expect(JSON.parse(utilizationCategoriesResult.content)).toEqual([ + { + id: "util_billable", + code: "BILLABLE", + name: "Billable", + description: "Client work", + projectCount: 3, + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-master-data-org-units-read.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-org-units-read.test.ts new file mode 100644 index 0000000..cb896ea --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-org-units-read.test.ts @@ -0,0 +1,95 @@ +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, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { countPlanningEntries } from "@capakraken/application"; +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js"; + +describe("assistant master data org units read tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + }); + + it("routes org unit reads through their backing router", async () => { + const db = { + orgUnit: { + findMany: vi.fn().mockResolvedValue([ + { + id: "ou_delivery", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: "ou_delivery", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + parent: null, + children: [], + _count: { resources: 7 }, + }), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_ALL_RESOURCES], + }); + + const orgUnitsResult = await executeTool( + "list_org_units", + JSON.stringify({ level: 5 }), + ctx, + ); + + expect(db.orgUnit.findMany).toHaveBeenCalledWith({ + where: { + level: 5, + isActive: true, + }, + orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], + }); + expect(db.orgUnit.findUnique).toHaveBeenCalledWith({ + where: { id: "ou_delivery" }, + include: { + parent: true, + children: { orderBy: { sortOrder: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + + expect(JSON.parse(orgUnitsResult.content)).toEqual([ + { + id: "ou_delivery", + name: "Delivery", + shortName: "DEL", + level: 5, + parent: null, + resourceCount: 7, + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-master-data-read-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-master-data-read-test-helpers.ts new file mode 100644 index 0000000..dceafe0 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-read-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, + }; +}