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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user