import { AllocationStatus, SystemRole } from "@nexus/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { allocationRouter } from "../router/allocation/index.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); }); });