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) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
281 lines
8.3 KiB
TypeScript
281 lines
8.3 KiB
TypeScript
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,
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 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);
|
||
});
|
||
});
|