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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 10:20:37 +02:00
parent 3e8df09cd8
commit 24435a1824
@@ -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<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);
});
});