diff --git a/packages/api/src/__tests__/assistant-tools-pending-vacation-approvals.test.ts b/packages/api/src/__tests__/assistant-tools-pending-vacation-approvals.test.ts new file mode 100644 index 0000000..50c9643 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-pending-vacation-approvals.test.ts @@ -0,0 +1,66 @@ +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, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-holiday-capacity-test-helpers.js"; + +describe("assistant pending vacation approvals tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes pending vacation approvals through the vacation router path", async () => { + const db = { + vacation: { + findMany: vi.fn().mockResolvedValue([ + { + id: "vac_1", + type: "ANNUAL", + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-03T00:00:00.000Z"), + isHalfDay: false, + resource: { displayName: "Bruce Banner", eid: "BB-1", chapter: "CGI" }, + requestedBy: { id: "user_2", name: "Manager", email: "manager@example.com" }, + }, + ]), + }, + }; + const ctx = createToolContext(db, [], SystemRole.MANAGER); + + const result = await executeTool( + "get_pending_vacation_approvals", + JSON.stringify({ limit: 10 }), + ctx, + ); + + expect(db.vacation.findMany).toHaveBeenCalledWith({ + where: { status: "PENDING" }, + include: { + resource: { select: expect.any(Object) }, + requestedBy: { select: { id: true, name: true, email: true } }, + }, + orderBy: { startDate: "asc" }, + }); + expect(JSON.parse(result.content)).toEqual([ + expect.objectContaining({ + id: "vac_1", + resource: "Bruce Banner", + eid: "BB-1", + chapter: "CGI", + }), + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-vacation-approval-errors.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-approval-errors.test.ts new file mode 100644 index 0000000..214efd9 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-vacation-approval-errors.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +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, type ToolContext } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-vacation-entitlement-test-helpers.js"; + +describe("assistant vacation approval error paths", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when vacation approval cannot resolve the request", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" }), + ), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "approve_vacation", + JSON.stringify({ vacationId: "vac_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when vacation approval violates lifecycle preconditions", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "approve_vacation", + JSON.stringify({ vacationId: "vac_approved" }), + { + ...ctx, + db: { + ...ctx.db, + vacation: { + ...((ctx.db as Record).vacation as Record), + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }) + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + update: vi.fn().mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved", + }), + ), + }, + } as ToolContext["db"], + }, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation cannot be approved in its current status.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-vacation-cancellation-errors.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-cancellation-errors.test.ts new file mode 100644 index 0000000..c65be47 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-vacation-cancellation-errors.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { VacationStatus } from "@capakraken/db"; +import { SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +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, type ToolContext } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-vacation-entitlement-test-helpers.js"; + +describe("assistant vacation cancellation error paths", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when vacation cancellation cannot resolve the request", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" }), + ), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "cancel_vacation", + JSON.stringify({ vacationId: "vac_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when vacation cancellation violates lifecycle preconditions", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_cancelled", + requestedById: "user_1", + resource: { displayName: "Alice Example", userId: "user_1" }, + }), + }, + }, + { userRole: SystemRole.USER, permissions: [] }, + ); + + const result = await executeTool( + "cancel_vacation", + JSON.stringify({ vacationId: "vac_cancelled" }), + { + ...ctx, + db: { + ...ctx.db, + vacation: { + ...((ctx.db as Record).vacation as Record), + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "vac_cancelled", + requestedById: "user_1", + resource: { displayName: "Alice Example", userId: "user_1" }, + }) + .mockResolvedValueOnce({ + id: "vac_cancelled", + status: VacationStatus.CANCELLED, + resource: { displayName: "Alice Example" }, + }), + update: vi.fn().mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Already cancelled", + }), + ), + }, + } as ToolContext["db"], + }, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation cannot be cancelled in its current status.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-vacation-rejection-errors.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-rejection-errors.test.ts new file mode 100644 index 0000000..d459967 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-vacation-rejection-errors.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +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, type ToolContext } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-vacation-entitlement-test-helpers.js"; + +describe("assistant vacation rejection error paths", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when vacation rejection cannot resolve the request", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" }), + ), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "reject_vacation", + JSON.stringify({ vacationId: "vac_missing", reason: "Capacity freeze" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when vacation rejection violates lifecycle preconditions", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "reject_vacation", + JSON.stringify({ vacationId: "vac_approved", reason: "Capacity freeze" }), + { + ...ctx, + db: { + ...ctx.db, + vacation: { + ...((ctx.db as Record).vacation as Record), + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }) + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + update: vi.fn().mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING vacations can be rejected", + }), + ), + }, + } as ToolContext["db"], + }, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation cannot be rejected in its current status.", + }); + }); +});