diff --git a/packages/api/src/__tests__/assistant-tools-vacation-create.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-create.test.ts new file mode 100644 index 0000000..a22c895 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-vacation-create.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { VacationType } from "@capakraken/db"; +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 { + createHappyPathDb, + createToolContext, + executeTool, +} from "./assistant-tools-vacation-mutation-test-helpers.js"; + +describe("assistant vacation mutation tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates vacation through the real vacation router path", async () => { + const db = createHappyPathDb(); + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "res_1", + type: "ANNUAL", + startDate: "2026-07-01", + endDate: "2026-07-02", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + vacationId: "vac_created", + message: + "Created ANNUAL for Alice Example: 2026-07-01 to 2026-07-02 (status: APPROVED, deducted 2 day(s))", + }), + ); + expect(db.vacation.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + resourceId: "res_1", + type: VacationType.ANNUAL, + status: "APPROVED", + requestedById: "user_1", + approvedById: "user_1", + }), + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-vacation-creation-errors.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-creation-errors.test.ts new file mode 100644 index 0000000..a8ff682 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-vacation-creation-errors.test.ts @@ -0,0 +1,164 @@ +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-vacation-entitlement-test-helpers.js"; + +describe("assistant vacation creation error paths", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when vacation creation receives an invalid end date", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + chapter: "Delivery", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "EMP-001", + type: "ANNUAL", + startDate: "2026-09-07", + endDate: "2026-09-99", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid endDate: 2026-09-99", + }); + }); + + it("returns a stable assistant error when vacation creation overlaps an existing request", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + chapter: "Delivery", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "MANAGER" }), + }, + vacation: { + findFirst: vi.fn().mockResolvedValue({ + id: "vac_existing", + resourceId: "res_1", + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "EMP-001", + type: "ANNUAL", + startDate: "2026-09-07", + endDate: "2026-09-09", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Overlapping vacation already exists for this resource in the selected period", + }); + }); + + it("returns a stable assistant error when a user tries to create vacation for another resource", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + chapter: "Delivery", + isActive: true, + }) + .mockResolvedValueOnce({ + userId: "user_2", + }), + findFirst: vi.fn().mockResolvedValue({ + id: "res_1", + }), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }), + }, + }, + { userRole: SystemRole.USER }, + ); + + const result = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "EMP-001", + type: "ANNUAL", + startDate: "2026-09-07", + endDate: "2026-09-09", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "You can only create vacation requests for your own resource.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-vacation-mutation-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-vacation-mutation-test-helpers.ts new file mode 100644 index 0000000..fa70025 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-vacation-mutation-test-helpers.ts @@ -0,0 +1,164 @@ +import { VacationType } from "@capakraken/db"; +import { vi } from "vitest"; + +import { + executeTool as executeAssistantTool, +} from "../router/assistant-tools.js"; + +export { createToolContext } from "./assistant-tools-vacation-entitlement-test-helpers.js"; + +export function createHappyPathDb() { + const vacationFindUnique = vi.fn().mockImplementation(async (args?: any) => { + const id = args?.where?.id; + if (id === "vac_cancelled") { + return { + id, + resourceId: "res_1", + requestedById: "user_1", + status: "CANCELLED", + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + type: VacationType.ANNUAL, + isHalfDay: false, + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + approvedBy: null, + }; + } + if (id === "vac_pending") { + return { + id, + resourceId: "res_1", + requestedById: "user_1", + status: "PENDING", + startDate: new Date("2026-08-03T00:00:00.000Z"), + endDate: new Date("2026-08-04T00:00:00.000Z"), + type: VacationType.ANNUAL, + isHalfDay: false, + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + approvedBy: null, + }; + } + if (id === "vac_self") { + return { + id, + resourceId: "res_1", + requestedById: "user_1", + status: "APPROVED", + startDate: new Date("2026-09-07T00:00:00.000Z"), + endDate: new Date("2026-09-09T00:00:00.000Z"), + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + approvedBy: null, + }; + } + return null; + }); + + return { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "MANAGER" }), + }, + resource: { + findUnique: vi.fn().mockImplementation(async (args?: any) => { + if (args?.where?.id === "res_1") { + return { + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + userId: "user_1", + chapter: "Delivery", + federalState: "BY", + countryId: "country_de", + metroCityId: null, + country: { code: "DE", name: "Germany" }, + metroCity: null, + }; + } + return { userId: "user_1" }; + }), + count: vi.fn().mockResolvedValue(1), + findFirst: vi.fn().mockResolvedValue(null), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn() + .mockResolvedValueOnce({ vacationDefaultDays: 30 }) + .mockResolvedValueOnce({ anonymizationEnabled: false }), + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn(), + update: vi.fn(), + }, + vacation: { + findUnique: vacationFindUnique, + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: "vac_created", + resourceId: "res_1", + status: "APPROVED", + type: VacationType.ANNUAL, + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + isHalfDay: false, + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + effectiveDays: 2, + }), + update: vi.fn().mockImplementation(async (args?: any) => { + const existing = await vacationFindUnique({ where: { id: args?.where?.id } }); + return { + ...(existing ?? { + id: args?.where?.id ?? "vac_unknown", + resourceId: "res_1", + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + type: VacationType.ANNUAL, + isHalfDay: false, + }), + status: args?.data?.status ?? existing?.status ?? "APPROVED", + rejectionReason: args?.data?.rejectionReason ?? null, + approvedAt: args?.data?.approvedAt ?? null, + approvedById: args?.data?.approvedById ?? existing?.approvedById ?? null, + }; + }), + }, + notification: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + create: vi.fn().mockResolvedValue({ id: "note_1", userId: "user_1" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; +} + +export const executeTool = executeAssistantTool; diff --git a/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts new file mode 100644 index 0000000..285fc5f --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts @@ -0,0 +1,110 @@ +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 { + createHappyPathDb, + createToolContext, + executeTool, +} from "./assistant-tools-vacation-mutation-test-helpers.js"; + +describe("assistant vacation mutation tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("approves and rejects vacation through the real vacation router path", async () => { + const db = createHappyPathDb(); + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const approveResult = await executeTool( + "approve_vacation", + JSON.stringify({ vacationId: "vac_cancelled" }), + ctx, + ); + const rejectResult = await executeTool( + "reject_vacation", + JSON.stringify({ vacationId: "vac_pending", reason: "Capacity freeze" }), + ctx, + ); + + expect(JSON.parse(approveResult.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Approved vacation for Alice Example", + }), + ); + expect(JSON.parse(rejectResult.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Rejected vacation for Alice Example: Capacity freeze", + }), + ); + expect(db.vacation.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "vac_cancelled" }, + data: expect.objectContaining({ status: "APPROVED" }), + }), + ); + expect(db.vacation.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "vac_pending" }, + data: expect.objectContaining({ status: "REJECTED", rejectionReason: "Capacity freeze" }), + }), + ); + }); + + it("allows self-service vacation cancellation through the real vacation router path", async () => { + const db = createHappyPathDb(); + const ctx = createToolContext(db, { userRole: SystemRole.USER, permissions: [] }); + + const result = await executeTool( + "cancel_vacation", + JSON.stringify({ vacationId: "vac_self" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Cancelled vacation for Alice Example", + }), + ); + expect(db.vacation.update).toHaveBeenCalledWith({ + where: { id: "vac_self" }, + data: { status: "CANCELLED" }, + }); + }); +});