Files
Nexus/packages/api/src/__tests__/allocation-conflict-check.test.ts
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

281 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>) {
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,
},
});
}
/** MondayFriday 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();
// MonFri = 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);
});
});