diff --git a/packages/api/src/__tests__/assistant-tools-advanced-project-shift-preview.test.ts b/packages/api/src/__tests__/assistant-tools-advanced-project-shift-preview.test.ts new file mode 100644 index 0000000..286f326 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-advanced-project-shift-preview.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../sse/event-bus.js", () => ({ + emitAllocationCreated: vi.fn(), + emitAllocationDeleted: vi.fn(), + emitAllocationUpdated: vi.fn(), + emitProjectShifted: vi.fn(), +})); + +vi.mock("../lib/budget-alerts.js", () => ({ + checkBudgetThresholds: vi.fn(), +})); + +vi.mock("../lib/cache.js", () => ({ + invalidateDashboardCache: vi.fn(), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-advanced-timeline-test-helpers.js"; + +describe("assistant advanced project shift preview tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns project shift preview details from the canonical timeline router", async () => { + const projectFindUnique = vi.fn().mockImplementation((args: { where?: { id?: string; shortCode?: string }; select?: Record }) => { + if (args.where?.id === "GDM") { + return Promise.resolve(null); + } + if (args.where?.shortCode === "GDM") { + return Promise.resolve({ + id: "project_shift", + name: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + responsiblePerson: "Larissa", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + }); + } + if (args.select && "budgetCents" in args.select) { + return Promise.resolve({ + id: "project_shift", + budgetCents: 100000, + winProbability: 100, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + }); + } + + return Promise.resolve({ + id: "project_shift", + name: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + responsiblePerson: "Larissa", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + }); + }); + + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + findFirst: vi.fn(), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + [PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + ); + + const result = await executeTool( + "preview_project_shift", + JSON.stringify({ + projectIdentifier: "GDM", + newStartDate: "2026-01-19", + newEndDate: "2026-01-30", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + project: { + id: "project_shift", + name: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + responsiblePerson: "Larissa", + startDate: "2026-01-05", + endDate: "2026-01-16", + }, + requestedShift: { + newStartDate: "2026-01-19", + newEndDate: "2026-01-30", + }, + preview: { + valid: true, + errors: [], + warnings: [], + conflictDetails: [], + costImpact: { + currentTotalCents: 0, + newTotalCents: 0, + deltaCents: 0, + budgetCents: 100000, + budgetUtilizationBefore: 0, + budgetUtilizationAfter: 0, + wouldExceedBudget: false, + }, + }, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-advanced-project-timeline-context.test.ts b/packages/api/src/__tests__/assistant-tools-advanced-project-timeline-context.test.ts new file mode 100644 index 0000000..0a77cb1 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-advanced-project-timeline-context.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../sse/event-bus.js", () => ({ + emitAllocationCreated: vi.fn(), + emitAllocationDeleted: vi.fn(), + emitAllocationUpdated: vi.fn(), + emitProjectShifted: vi.fn(), +})); + +vi.mock("../lib/budget-alerts.js", () => ({ + checkBudgetThresholds: vi.fn(), +})); + +vi.mock("../lib/cache.js", () => ({ + invalidateDashboardCache: vi.fn(), +})); + +import { listAssignmentBookings } from "@capakraken/application"; +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-advanced-timeline-test-helpers.js"; + +describe("assistant advanced project timeline context tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + }); + + it("returns project timeline context with cross-project overlap summaries", async () => { + const project = { + id: "project_ctx", + name: "Gelddruckmaschine", + shortCode: "GDM", + orderType: "CHARGEABLE", + budgetCents: 100000, + winProbability: 100, + status: "ACTIVE", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + staffingReqs: null, + }; + + vi.mocked(listAssignmentBookings).mockResolvedValue([ + { + id: "asg_project", + projectId: "project_ctx", + resourceId: "res_1", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + hoursPerDay: 6, + dailyCostCents: 0, + status: "CONFIRMED", + project: { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null }, + resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" }, + }, + { + id: "asg_other", + projectId: "project_other", + resourceId: "res_1", + startDate: new Date("2026-01-08T00:00:00.000Z"), + endDate: new Date("2026-01-10T00:00:00.000Z"), + hoursPerDay: 4, + dailyCostCents: 0, + status: "CONFIRMED", + project: { id: "project_other", name: "Other Project", shortCode: "OTH", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null }, + resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" }, + }, + ]); + + const ctx = createToolContext( + { + project: { + findUnique: vi + .fn() + .mockResolvedValueOnce(project) + .mockResolvedValueOnce(project), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([ + { + id: "dem_ctx", + projectId: "project_ctx", + resourceId: null, + role: "Artist", + hoursPerDay: 6, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + status: "OPEN", + metadata: null, + project, + roleEntity: null, + }, + ]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "asg_project", + projectId: "project_ctx", + resourceId: "res_1", + role: "Artist", + hoursPerDay: 6, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + status: "CONFIRMED", + metadata: null, + resource: { + id: "res_1", + displayName: "Alice", + eid: "EMP-1", + chapter: "Delivery", + lcrCents: 10000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }, + project, + roleEntity: null, + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + countryId: "country_de", + federalState: "BY", + metroCityId: "city_munich", + country: { code: "DE" }, + metroCity: { name: "Muenchen" }, + }, + ]), + }, + }, + [PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + ); + + const result = await executeTool( + "get_project_timeline_context", + JSON.stringify({ + projectIdentifier: "project_ctx", + }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + project: { id: string; shortCode: string }; + summary: { + demandCount: number; + assignmentCount: number; + conflictedAssignmentCount: number; + overlayCount: number; + }; + assignmentConflicts: Array<{ + assignmentId: string; + crossProjectOverlapCount: number; + overlaps: Array<{ projectShortCode: string; sameProject: boolean }>; + }>; + holidayOverlays: Array<{ startDate: string }>; + }; + + expect(parsed.project).toEqual( + expect.objectContaining({ + id: "project_ctx", + shortCode: "GDM", + }), + ); + expect(parsed.summary).toEqual( + expect.objectContaining({ + demandCount: 1, + assignmentCount: 1, + conflictedAssignmentCount: 1, + overlayCount: 1, + }), + ); + expect(parsed.assignmentConflicts).toEqual([ + expect.objectContaining({ + assignmentId: "asg_project", + crossProjectOverlapCount: 1, + overlaps: expect.arrayContaining([ + expect.objectContaining({ + projectShortCode: "OTH", + sameProject: false, + }), + ]), + }), + ]); + expect(parsed.holidayOverlays).toEqual([ + expect.objectContaining({ + startDate: "2026-01-06", + }), + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-advanced-timeline-entries-view.test.ts b/packages/api/src/__tests__/assistant-tools-advanced-timeline-entries-view.test.ts new file mode 100644 index 0000000..1e03a10 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-advanced-timeline-entries-view.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../sse/event-bus.js", () => ({ + emitAllocationCreated: vi.fn(), + emitAllocationDeleted: vi.fn(), + emitAllocationUpdated: vi.fn(), + emitProjectShifted: vi.fn(), +})); + +vi.mock("../lib/budget-alerts.js", () => ({ + checkBudgetThresholds: vi.fn(), +})); + +vi.mock("../lib/cache.js", () => ({ + invalidateDashboardCache: vi.fn(), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-advanced-timeline-test-helpers.js"; + +describe("assistant advanced timeline entries view tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns timeline entries view with demand, assignment, and holiday overlay context", async () => { + const ctx = createToolContext( + { + demandRequirement: { + findMany: vi.fn().mockResolvedValue([ + { + id: "dem_1", + projectId: "project_1", + resourceId: null, + role: "Artist", + hoursPerDay: 8, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-09T00:00:00.000Z"), + status: "OPEN", + metadata: null, + project: { + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + orderType: "CHARGEABLE", + clientId: "client_1", + budgetCents: 0, + winProbability: 100, + status: "ACTIVE", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + staffingReqs: null, + responsiblePerson: "Larissa", + color: "#fff", + }, + roleEntity: null, + }, + ]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "asg_by", + projectId: "project_1", + resourceId: "res_by", + role: "Artist", + hoursPerDay: 8, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-09T00:00:00.000Z"), + status: "CONFIRMED", + metadata: null, + resource: { + id: "res_by", + displayName: "Bayern User", + eid: "EMP-BY", + chapter: "Delivery", + lcrCents: 10000, + }, + project: { + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + orderType: "CHARGEABLE", + clientId: "client_1", + budgetCents: 0, + winProbability: 100, + status: "ACTIVE", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + staffingReqs: null, + responsiblePerson: "Larissa", + color: "#fff", + }, + roleEntity: null, + }, + { + id: "asg_hh", + projectId: "project_1", + resourceId: "res_hh", + role: "Artist", + hoursPerDay: 8, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-09T00:00:00.000Z"), + status: "CONFIRMED", + metadata: null, + resource: { + id: "res_hh", + displayName: "Hamburg User", + eid: "EMP-HH", + chapter: "Delivery", + lcrCents: 10000, + }, + project: { + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + orderType: "CHARGEABLE", + clientId: "client_1", + budgetCents: 0, + winProbability: 100, + status: "ACTIVE", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + staffingReqs: null, + responsiblePerson: "Larissa", + color: "#fff", + }, + roleEntity: null, + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_by", + countryId: "country_de", + federalState: "BY", + metroCityId: "city_munich", + country: { code: "DE" }, + metroCity: { name: "Muenchen" }, + }, + { + id: "res_hh", + countryId: "country_de", + federalState: "HH", + metroCityId: "city_hamburg", + country: { code: "DE" }, + metroCity: { name: "Hamburg" }, + }, + ]), + }, + project: { + findMany: vi.fn(), + }, + }, + [PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + ); + + const result = await executeTool( + "get_timeline_entries_view", + JSON.stringify({ + startDate: "2026-01-05", + endDate: "2026-01-09", + projectIds: ["project_1"], + }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + summary: { + demandCount: number; + assignmentCount: number; + overlayCount: number; + resourceCount: number; + }; + demands: Array<{ id: string }>; + assignments: Array<{ id: string }>; + holidayOverlays: Array<{ resourceId: string; startDate: string; note: string; scope: string }>; + }; + + expect(parsed.summary).toEqual( + expect.objectContaining({ + demandCount: 1, + assignmentCount: 2, + overlayCount: 1, + resourceCount: 2, + }), + ); + expect(parsed.demands).toHaveLength(1); + expect(parsed.assignments).toHaveLength(2); + expect(parsed.holidayOverlays).toEqual([ + expect.objectContaining({ + resourceId: "res_by", + startDate: "2026-01-06", + note: "Heilige Drei Könige", + scope: "STATE", + }), + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-advanced-timeline-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-advanced-timeline-test-helpers.ts new file mode 100644 index 0000000..15f9ed7 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-advanced-timeline-test-helpers.ts @@ -0,0 +1,26 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +import type { ToolContext } from "../router/assistant-tools.js"; + +export function createToolContext( + db: Record, + permissions: PermissionKey[] = [], + userRole: SystemRole = SystemRole.ADMIN, +): ToolContext { + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(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, + }; +}