From 24435a18240781d60ac4b37bc62d65f19960a055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 10:20:37 +0200 Subject: [PATCH] test(allocation): add conflict check tests for checkConflicts query Covers: no-conflict baseline, overbooking detection with per-day breakdown, vacation overlap reporting, edit-mode excludeAssignmentId exclusion, NOT_FOUND guard, and fallback country-hours capacity path. Co-Authored-By: Claude Sonnet 4.6 --- .../allocation-conflict-check.test.ts | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 packages/api/src/__tests__/allocation-conflict-check.test.ts diff --git a/packages/api/src/__tests__/allocation-conflict-check.test.ts b/packages/api/src/__tests__/allocation-conflict-check.test.ts new file mode 100644 index 0000000..683427b --- /dev/null +++ b/packages/api/src/__tests__/allocation-conflict-check.test.ts @@ -0,0 +1,280 @@ +import { AllocationStatus, SystemRole } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { allocationRouter } from "../router/allocation.js"; +import { createCallerFactory } from "../trpc.js"; + +vi.mock("../sse/event-bus.js", () => ({ + emitAllocationCreated: vi.fn(), + emitAllocationDeleted: vi.fn(), + emitAllocationUpdated: vi.fn(), + emitNotificationCreated: vi.fn(), +})); + +vi.mock("../lib/budget-alerts.js", () => ({ + checkBudgetThresholds: vi.fn(), +})); + +vi.mock("../lib/cache.js", () => ({ + invalidateDashboardCache: vi.fn(), +})); + +vi.mock("../lib/auto-staffing.js", () => ({ + generateAutoSuggestions: vi.fn(), +})); + +vi.mock("../lib/webhook-dispatcher.js", () => ({ + dispatchWebhooks: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../lib/logger.js", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +const createCaller = createCallerFactory(allocationRouter); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2026-12-31T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + }); +} + +/** Monday–Friday week, all days 8h/day capacity */ +const BASE_AVAILABILITY = { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, +}; + +const RESOURCE_BASE = { + availability: BASE_AVAILABILITY, + fte: 1, + country: { dailyWorkingHours: 8 }, +}; + +describe("allocation.checkConflicts", () => { + it("returns no conflict when the resource has no existing assignments", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(RESOURCE_BASE), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.checkConflicts({ + resourceId: "res_1", + startDate: new Date("2026-05-04T00:00:00.000Z"), // Monday + endDate: new Date("2026-05-08T00:00:00.000Z"), // Friday + hoursPerDay: 8, + }); + + expect(result.isOverbooking).toBe(false); + expect(result.overbooking).toBeNull(); + expect(result.hasVacationOverlap).toBe(false); + expect(result.vacationOverlap).toHaveLength(0); + }); + + it("detects overbooking when existing + requested hours exceed daily capacity", async () => { + // 2026-05-04 (Mon) – 2026-05-08 (Fri) = 5 working days + // Existing: 4h/day → requesting another 6h = 10h total on 8h capacity + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(RESOURCE_BASE), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + startDate: new Date("2026-05-04T00:00:00.000Z"), + endDate: new Date("2026-05-08T00:00:00.000Z"), + hoursPerDay: 4, + status: AllocationStatus.ACTIVE, + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.checkConflicts({ + resourceId: "res_1", + startDate: new Date("2026-05-04T00:00:00.000Z"), + endDate: new Date("2026-05-08T00:00:00.000Z"), + hoursPerDay: 6, + }); + + expect(result.isOverbooking).toBe(true); + expect(result.overbooking).not.toBeNull(); + // Mon–Fri = 5 conflict days + expect(result.overbooking!.totalConflictDays).toBe(5); + expect(result.overbooking!.conflictDays).toHaveLength(5); + // Each day: 4 existing + 6 requested = 10 > 8 → overage 2h + const day = result.overbooking!.conflictDays[0]!; + expect(day.existingHours).toBe(4); + expect(day.requestedHours).toBe(6); + expect(day.availableHours).toBe(8); + expect(day.overageHours).toBeCloseTo(2); + // maxOverbookPercent: (10/8 - 1) * 100 = 25% + expect(result.overbooking!.maxOverbookPercent).toBe(25); + expect(result.hasVacationOverlap).toBe(false); + }); + + it("reports vacation overlap when the resource has approved leave in the period", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(RESOURCE_BASE), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([ + { + startDate: new Date("2026-05-07T00:00:00.000Z"), + endDate: new Date("2026-05-07T00:00:00.000Z"), + type: "ANNUAL_LEAVE", + isHalfDay: false, + }, + ]), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.checkConflicts({ + resourceId: "res_1", + startDate: new Date("2026-05-04T00:00:00.000Z"), + endDate: new Date("2026-05-08T00:00:00.000Z"), + hoursPerDay: 4, + }); + + expect(result.isOverbooking).toBe(false); + expect(result.hasVacationOverlap).toBe(true); + expect(result.vacationOverlap).toHaveLength(1); + expect(result.vacationOverlap[0]!.type).toBe("ANNUAL_LEAVE"); + expect(result.vacationOverlap[0]!.startDate).toBe("2026-05-07"); + expect(result.vacationOverlap[0]!.endDate).toBe("2026-05-07"); + expect(result.vacationOverlap[0]!.isHalfDay).toBe(false); + }); + + it("excludes the current assignment when excludeAssignmentId is provided (edit mode)", async () => { + // Editing assignment asn_current (8h/day). Without exclusion it would double-count itself. + // With exclusion the existing load is 0 → no overbooking for 8h request. + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(RESOURCE_BASE), + }, + assignment: { + // Simulate that Prisma respects the `id: { not: excludeAssignmentId }` filter + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.checkConflicts({ + resourceId: "res_1", + startDate: new Date("2026-05-04T00:00:00.000Z"), + endDate: new Date("2026-05-08T00:00:00.000Z"), + hoursPerDay: 8, + excludeAssignmentId: "asn_current", + }); + + // Verify the exclusion filter was passed to Prisma + expect(db.assignment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: { not: "asn_current" }, + }), + }), + ); + + expect(result.isOverbooking).toBe(false); + }); + + it("throws NOT_FOUND when the resource does not exist", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + assignment: { findMany: vi.fn() }, + vacation: { findMany: vi.fn() }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.checkConflicts({ + resourceId: "nonexistent", + startDate: new Date("2026-05-05T00:00:00.000Z"), + endDate: new Date("2026-05-09T00:00:00.000Z"), + hoursPerDay: 4, + }), + ).rejects.toThrow("Resource not found"); + }); + + it("uses country working hours as fallback when resource has no availability set", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + availability: null, + fte: 1, + country: { dailyWorkingHours: 6 }, + }), + }, + assignment: { + // Existing 5h/day → requesting 4h = 9h > 6h fallback capacity + findMany: vi.fn().mockResolvedValue([ + { + startDate: new Date("2026-05-05T00:00:00.000Z"), + endDate: new Date("2026-05-05T00:00:00.000Z"), + hoursPerDay: 5, + status: AllocationStatus.ACTIVE, + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.checkConflicts({ + resourceId: "res_1", + startDate: new Date("2026-05-05T00:00:00.000Z"), + endDate: new Date("2026-05-05T00:00:00.000Z"), + hoursPerDay: 4, + }); + + expect(result.isOverbooking).toBe(true); + expect(result.overbooking!.conflictDays[0]!.availableHours).toBe(6); + }); +});