feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { AllocationStatus, SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { allocationRouter } from "../router/allocation.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted } from "../sse/event-bus.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.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", () => ({
|
||||
@@ -18,6 +19,10 @@ vi.mock("../lib/cache.js", () => ({
|
||||
invalidateDashboardCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(allocationRouter);
|
||||
|
||||
function createManagerCaller(db: Record<string, unknown>) {
|
||||
@@ -35,7 +40,100 @@ function createManagerCaller(db: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
function createDemandWorkflowDb(overrides: Record<string, unknown> = {}) {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Project One" }),
|
||||
},
|
||||
role: {
|
||||
findUnique: vi.fn().mockResolvedValue({ name: "FX Artist" }),
|
||||
},
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||
},
|
||||
notification: {
|
||||
create: vi.fn().mockImplementation(async ({ data }: { data: { userId: string } }) => ({
|
||||
id: `notif_${data.userId}`,
|
||||
})),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...db,
|
||||
...overrides,
|
||||
project: { ...db.project, ...(overrides.project as Record<string, unknown> | undefined) },
|
||||
role: { ...db.role, ...(overrides.role as Record<string, unknown> | undefined) },
|
||||
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||
notification: {
|
||||
...db.notification,
|
||||
...(overrides.notification as Record<string, unknown> | undefined),
|
||||
},
|
||||
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||
};
|
||||
}
|
||||
|
||||
describe("allocation entry resolution router", () => {
|
||||
it("excludes regional holidays from resource availability coverage", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "resource_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "E-001",
|
||||
fte: 1,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { dailyWorkingHours: 8, code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.checkResourceAvailability({
|
||||
resourceId: "resource_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dailyCapacity: 8,
|
||||
totalWorkingDays: 1,
|
||||
availableDays: 0,
|
||||
partialDays: 0,
|
||||
conflictDays: 1,
|
||||
totalAvailableHours: 0,
|
||||
totalRequestedHours: 8,
|
||||
coveragePercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
|
||||
const createdDemandRequirement = {
|
||||
id: "demand_1",
|
||||
@@ -187,6 +285,7 @@ describe("allocation entry resolution router", () => {
|
||||
|
||||
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
|
||||
vi.mocked(emitAllocationCreated).mockClear();
|
||||
vi.mocked(emitNotificationCreated).mockClear();
|
||||
|
||||
const createdDemandRequirement = {
|
||||
id: "demand_explicit_1",
|
||||
@@ -206,18 +305,14 @@ describe("allocation entry resolution router", () => {
|
||||
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
|
||||
};
|
||||
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
||||
},
|
||||
const db = createDemandWorkflowDb({
|
||||
demandRequirement: {
|
||||
create: vi.fn().mockResolvedValue(createdDemandRequirement),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
Object.assign(db, {
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.createDemandRequirement({
|
||||
@@ -247,6 +342,8 @@ describe("allocation entry resolution router", () => {
|
||||
projectId: "project_1",
|
||||
resourceId: null,
|
||||
});
|
||||
expect(db.notification.create).toHaveBeenCalledTimes(2);
|
||||
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
|
||||
@@ -730,4 +827,3 @@ describe("allocation entry resolution router", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAssistantInsight } from "../router/assistant-insights.js";
|
||||
|
||||
describe("assistant insights", () => {
|
||||
it("builds a transparent chargeability insight from holiday-aware payloads", () => {
|
||||
const insight = buildAssistantInsight("get_chargeability", {
|
||||
resource: "Bruce Banner",
|
||||
month: "2026-01",
|
||||
chargeability: "42.9%",
|
||||
chargeabilityPct: 42.9,
|
||||
targetPct: 80,
|
||||
availableHours: 168,
|
||||
bookedHours: 72,
|
||||
unassignedHours: 96,
|
||||
targetHours: 134.4,
|
||||
baseWorkingDays: 23,
|
||||
workingDays: 21,
|
||||
baseAvailableHours: 184,
|
||||
locationContext: { country: "Deutschland", federalState: "BY", metroCity: "Augsburg" },
|
||||
holidaySummary: { count: 2, workdayCount: 2, hoursDeduction: 16 },
|
||||
absenceSummary: { dayEquivalent: 0.5, hoursDeduction: 4 },
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "chargeability",
|
||||
title: "Bruce Banner · 2026-01",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Chargeability", value: "42.9%", tone: "warn" }),
|
||||
expect.objectContaining({ label: "Available", value: "168 h" }),
|
||||
expect.objectContaining({ label: "Target", value: "134.4 h" }),
|
||||
]),
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Basis",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Location", value: "Augsburg, BY, Deutschland" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Deductions",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Holiday deduction", value: "16 h" }),
|
||||
expect.objectContaining({ label: "Absence deduction", value: "4 h" }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a holiday comparison insight with regional scope counts", () => {
|
||||
const insight = buildAssistantInsight("list_holidays_by_region", {
|
||||
locationContext: { countryCode: "DE", federalState: "BY" },
|
||||
count: 14,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
summary: {
|
||||
byScope: [
|
||||
{ scope: "NATIONAL", count: 9 },
|
||||
{ scope: "STATE", count: 5 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "holiday_region",
|
||||
title: "BY, DE",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Resolved holidays", value: "14" }),
|
||||
]),
|
||||
sections: [
|
||||
expect.objectContaining({
|
||||
title: "Scopes",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "STATE", value: "5" }),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a best-resource insight from staffing recommendations", () => {
|
||||
const insight = buildAssistantInsight("find_best_project_resource", {
|
||||
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
|
||||
period: { startDate: "2026-04-01", endDate: "2026-04-21", minHoursPerDay: 3, rankingMode: "lowest_lcr" },
|
||||
candidateCount: 4,
|
||||
bestMatch: {
|
||||
name: "Jane Doe",
|
||||
role: "TD",
|
||||
chapter: "Lighting",
|
||||
country: "Deutschland",
|
||||
federalState: "BY",
|
||||
metroCity: "Muenchen",
|
||||
lcr: "€85.00",
|
||||
remainingHours: 74,
|
||||
remainingHoursPerDay: 3.5,
|
||||
availableHours: 120,
|
||||
baseAvailableHours: 136,
|
||||
holidaySummary: { hoursDeduction: 8 },
|
||||
absenceSummary: { hoursDeduction: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "resource_match",
|
||||
title: "GDM staffing",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Best match", value: "Jane Doe" }),
|
||||
expect.objectContaining({ label: "Remaining", value: "74 h", tone: "good" }),
|
||||
]),
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Selection",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Location", value: "Muenchen, BY, Deutschland" }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant.js";
|
||||
|
||||
function getToolNames(permissions: PermissionKeyValue[]) {
|
||||
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
describe("assistant router tool gating", () => {
|
||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
|
||||
const withAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
]);
|
||||
|
||||
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps user administration tools behind manageUsers", () => {
|
||||
const withoutManageUsers = getToolNames([]);
|
||||
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
|
||||
|
||||
expect(withoutManageUsers).not.toContain("list_users");
|
||||
expect(withManageUsers).toContain("list_users");
|
||||
});
|
||||
|
||||
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
||||
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
||||
|
||||
expect(names).not.toContain("find_best_project_resource");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PermissionKey } from "@capakraken/shared";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: PermissionKey[] = [],
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
permissions: new Set(permissions),
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant advanced tools and scoping", () => {
|
||||
it("finds the best project resource with holiday-aware remaining capacity and LCR ranking", async () => {
|
||||
const assignmentFindMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: "res_carol",
|
||||
hoursPerDay: 2,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "PROPOSED",
|
||||
resource: {
|
||||
id: "res_carol",
|
||||
eid: "carol.danvers",
|
||||
displayName: "Carol Danvers",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 7664,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
areaRole: { name: "Artist" },
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: "res_steve",
|
||||
hoursPerDay: 4,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
resource: {
|
||||
id: "res_steve",
|
||||
eid: "steve.rogers",
|
||||
displayName: "Steve Rogers",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 13377,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_augsburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
areaRole: { name: "Artist" },
|
||||
},
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: "res_carol",
|
||||
projectId: "project_lari",
|
||||
hoursPerDay: 2,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "PROPOSED",
|
||||
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||
},
|
||||
{
|
||||
resourceId: "res_steve",
|
||||
projectId: "project_lari",
|
||||
hoursPerDay: 4,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_lari",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "LARI",
|
||||
status: "ACTIVE",
|
||||
responsiblePerson: "Larissa Joos",
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: assignmentFindMany,
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_best_project_resource",
|
||||
JSON.stringify({
|
||||
projectIdentifier: "LARI",
|
||||
startDate: "2026-01-05",
|
||||
endDate: "2026-01-16",
|
||||
minHoursPerDay: 3,
|
||||
rankingMode: "lowest_lcr",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
project: { shortCode: string };
|
||||
candidateCount: number;
|
||||
bestMatch: {
|
||||
name: string;
|
||||
remainingHoursPerDay: number;
|
||||
lcrCents: number | null;
|
||||
federalState: string | null;
|
||||
metroCity: string | null;
|
||||
baseAvailableHours: number;
|
||||
holidaySummary: { count: number };
|
||||
};
|
||||
candidates: Array<{
|
||||
name: string;
|
||||
remainingHoursPerDay: number;
|
||||
workingDays: number;
|
||||
baseAvailableHours: number;
|
||||
holidaySummary: { count: number; hoursDeduction: number };
|
||||
capacityBreakdown: { holidayHoursDeduction: number };
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(parsed.project.shortCode).toBe("LARI");
|
||||
expect(parsed.candidateCount).toBe(2);
|
||||
expect(parsed.bestMatch).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Carol Danvers",
|
||||
remainingHoursPerDay: 6,
|
||||
lcrCents: 7664,
|
||||
federalState: "HH",
|
||||
metroCity: "Hamburg",
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 0 }),
|
||||
}),
|
||||
);
|
||||
expect(parsed.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Carol Danvers",
|
||||
remainingHoursPerDay: 6,
|
||||
workingDays: 10,
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
|
||||
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Steve Rogers",
|
||||
remainingHoursPerDay: 4,
|
||||
workingDays: 9,
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
|
||||
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires the dedicated advanced assistant permission for the high-level resource tool", async () => {
|
||||
const ctx = createToolContext({}, [PermissionKey.VIEW_COSTS]);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_best_project_resource",
|
||||
JSON.stringify({ projectIdentifier: "LARI" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes assistant notification listing to the current user", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([]);
|
||||
const ctx = createToolContext({
|
||||
notification: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
await executeTool("list_notifications", JSON.stringify({ unreadOnly: true }), ctx);
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
readAt: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects marking notifications that do not belong to the current user", async () => {
|
||||
const update = vi.fn();
|
||||
const ctx = createToolContext({
|
||||
notification: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
|
||||
update,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"mark_notification_read",
|
||||
JSON.stringify({ notificationId: "notif_1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Access denied: this notification does not belong to you",
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires manageUsers before listing users through the assistant", async () => {
|
||||
const findMany = vi.fn();
|
||||
const ctx = createToolContext({
|
||||
user: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining(PermissionKey.MANAGE_USERS),
|
||||
}),
|
||||
);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: string[] = [],
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant holiday tools", () => {
|
||||
it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
|
||||
const ctx = createToolContext({});
|
||||
|
||||
const bavaria = await executeTool(
|
||||
"list_holidays_by_region",
|
||||
JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
const hamburg = await executeTool(
|
||||
"list_holidays_by_region",
|
||||
JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const bavariaResult = JSON.parse(bavaria.content) as {
|
||||
count: number;
|
||||
locationContext: { federalState: string | null };
|
||||
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
const hamburgResult = JSON.parse(hamburg.content) as {
|
||||
count: number;
|
||||
locationContext: { federalState: string | null };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
|
||||
expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
|
||||
expect(bavariaResult.locationContext.federalState).toBe("BY");
|
||||
expect(bavariaResult.summary.byScope).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
|
||||
);
|
||||
expect(bavariaResult.holidays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]),
|
||||
);
|
||||
expect(hamburgResult.holidays).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves resource-specific holidays including city-local dates", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_resource_holidays",
|
||||
JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
resource: { eid: string; federalState: string | null; metroCity: string | null };
|
||||
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.resource).toEqual(
|
||||
expect.objectContaining({
|
||||
eid: "bruce.banner",
|
||||
federalState: "BY",
|
||||
metroCity: "Augsburg",
|
||||
}),
|
||||
);
|
||||
expect(parsed.holidays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
|
||||
]),
|
||||
);
|
||||
expect(parsed.summary.byScope).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "bruce.banner",
|
||||
fte: 1,
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_chargeability",
|
||||
JSON.stringify({ resourceId: "res_1", month: "2026-01" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
baseWorkingDays: number;
|
||||
baseAvailableHours: number;
|
||||
availableHours: number;
|
||||
bookedHours: number;
|
||||
workingDays: number;
|
||||
targetHours: number;
|
||||
unassignedHours: number;
|
||||
holidaySummary: { count: number; workdayCount: number; hoursDeduction: number };
|
||||
capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number };
|
||||
locationContext: { federalState: string | null };
|
||||
allocations: Array<{ hours: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.bookedHours).toBe(8);
|
||||
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
|
||||
expect(parsed.baseWorkingDays).toBe(23);
|
||||
expect(parsed.baseAvailableHours).toBe(184);
|
||||
expect(parsed.availableHours).toBe(168);
|
||||
expect(parsed.workingDays).toBe(21);
|
||||
expect(parsed.targetHours).toBe(134.4);
|
||||
expect(parsed.unassignedHours).toBe(160);
|
||||
expect(parsed.locationContext.federalState).toBe("BY");
|
||||
expect(parsed.holidaySummary).toEqual(
|
||||
expect.objectContaining({
|
||||
count: 2,
|
||||
workdayCount: 2,
|
||||
hoursDeduction: 16,
|
||||
}),
|
||||
);
|
||||
expect(parsed.capacityBreakdown).toEqual(
|
||||
expect.objectContaining({
|
||||
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
|
||||
holidayHoursDeduction: 16,
|
||||
absenceHoursDeduction: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
|
||||
const { getDashboardBudgetForecast } = await import("@capakraken/application");
|
||||
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||
{
|
||||
projectId: "project_1",
|
||||
projectName: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 60_000,
|
||||
burnRate: 5_000,
|
||||
pctUsed: 60,
|
||||
estimatedExhaustionDate: "2026-02-20",
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = createToolContext({}, ["viewCosts"]);
|
||||
const result = await executeTool("get_budget_forecast", "{}", ctx);
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
forecasts: Array<{
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
budgetCents: number;
|
||||
spentCents: number;
|
||||
remainingCents: number;
|
||||
projectedCents: number;
|
||||
burnRateCents: number;
|
||||
burnStatus: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(getDashboardBudgetForecast).toHaveBeenCalled();
|
||||
expect(parsed.forecasts).toEqual([
|
||||
expect.objectContaining({
|
||||
projectName: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 60_000,
|
||||
remainingCents: 40_000,
|
||||
projectedCents: 100_000,
|
||||
burnRateCents: 5_000,
|
||||
burnStatus: "on_track",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks resource availability with regional holidays excluded from capacity", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"check_resource_availability",
|
||||
JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
workingDays: number;
|
||||
periodAvailableHours: number;
|
||||
periodBookedHours: number;
|
||||
periodRemainingHours: number;
|
||||
availableHoursPerDay: number;
|
||||
isFullyAvailable: boolean;
|
||||
};
|
||||
|
||||
expect(parsed.workingDays).toBe(1);
|
||||
expect(parsed.periodAvailableHours).toBe(8);
|
||||
expect(parsed.periodBookedHours).toBe(8);
|
||||
expect(parsed.periodRemainingHours).toBe(0);
|
||||
expect(parsed.availableHoursPerDay).toBe(0);
|
||||
expect(parsed.isFullyAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
budgetCents: 500_000,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
resourceId: "res_1",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
resource: {
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
lcrCents: 100,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
lcrCents: 100,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db, ["manageAllocations"]);
|
||||
|
||||
const result = await executeTool(
|
||||
"simulate_scenario",
|
||||
JSON.stringify({
|
||||
projectId: "project_1",
|
||||
changes: [
|
||||
{
|
||||
resourceId: "res_1",
|
||||
startDate: "2026-01-06",
|
||||
endDate: "2026-01-06",
|
||||
hoursPerDay: 8,
|
||||
},
|
||||
],
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
baseline: { totalHours: number; totalCostCents: number };
|
||||
scenario: { totalHours: number; totalCostCents: number };
|
||||
delta: { hours: number; costCents: number };
|
||||
};
|
||||
|
||||
expect(parsed.baseline).toEqual(
|
||||
expect.objectContaining({
|
||||
totalHours: 8,
|
||||
totalCostCents: 800,
|
||||
}),
|
||||
);
|
||||
expect(parsed.scenario).toEqual(
|
||||
expect.objectContaining({
|
||||
totalHours: 8,
|
||||
totalCostCents: 800,
|
||||
}),
|
||||
);
|
||||
expect(parsed.delta).toEqual(
|
||||
expect.objectContaining({
|
||||
hours: 0,
|
||||
costCents: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers resources without a local holiday in staffing suggestions", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
shortCode: "HP",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bavaria",
|
||||
eid: "BY-1",
|
||||
fte: 1,
|
||||
lcrCents: 10000,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
{
|
||||
id: "res_hh",
|
||||
displayName: "Hamburg",
|
||||
eid: "HH-1",
|
||||
fte: 1,
|
||||
lcrCents: 10000,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_staffing_suggestions",
|
||||
JSON.stringify({ projectId: "project_1", limit: 5 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
suggestions: Array<{ name: string; availableHours: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.suggestions).toHaveLength(1);
|
||||
expect(parsed.suggestions[0]).toEqual(
|
||||
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds capacity with local holidays respected", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bavaria",
|
||||
eid: "BY-1",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
{
|
||||
id: "res_hh",
|
||||
displayName: "Hamburg",
|
||||
eid: "HH-1",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_capacity",
|
||||
JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0]).toEqual(
|
||||
expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
shortCode: "HP",
|
||||
shoringThreshold: 55,
|
||||
onshoreCountryCode: "DE",
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
resourceId: "res_by",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
resource: {
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: "res_in",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
resource: {
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_in",
|
||||
federalState: null,
|
||||
metroCityId: null,
|
||||
country: { code: "IN" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_shoring_ratio",
|
||||
JSON.stringify({ projectId: "project_1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.content).toContain("0% onshore (DE), 100% offshore");
|
||||
expect(result.content).toContain("IN 100% (1 people)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
|
||||
listAssignmentBookings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { checkChargeabilityAlerts } from "../lib/chargeability-alerts.js";
|
||||
|
||||
describe("chargeability alerts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates an alert when a regional holiday reduces booked hours below threshold", async () => {
|
||||
const notifications: Array<{ userId: string; title: string; body?: string }> = [];
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
federalState: "BY",
|
||||
chargeabilityTarget: 21,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
managementLevelGroup: { targetPercentage: 0.21 },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
notifications.push(data);
|
||||
return { id: `notification_${notifications.length}`, userId: data.userId };
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "manager_1" }]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Gamma",
|
||||
shortCode: "GAM",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "res_1", displayName: "Bruce Banner", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const alertCount = await checkChargeabilityAlerts(db);
|
||||
|
||||
expect(alertCount).toBe(1);
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]?.title).toContain("Bruce Banner");
|
||||
expect(notifications[0]?.body).toContain("gap: 16pp");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,10 @@ describe("chargeability report router", () => {
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_es",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_es",
|
||||
@@ -143,6 +147,10 @@ describe("chargeability report router", () => {
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_es",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_es",
|
||||
@@ -204,4 +212,217 @@ describe("chargeability report router", () => {
|
||||
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
|
||||
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
|
||||
});
|
||||
|
||||
it("reduces SAH for German public holidays based on the calendar", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_de",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_1", name: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "project_full_month", utilizationCategory: { code: "Chg" } },
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_full_month",
|
||||
projectId: "project_full_month",
|
||||
resourceId: "resource_de",
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||
hoursPerDay: 7,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_full_month",
|
||||
name: "Full Month Project",
|
||||
shortCode: "FMP",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_de", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2026-01",
|
||||
endMonth: "2026-01",
|
||||
});
|
||||
|
||||
const month = report.resources[0]?.months[0];
|
||||
|
||||
expect(month).toBeDefined();
|
||||
expect(month?.sah).toBe(168);
|
||||
expect(month?.chg).toBeCloseTo(0.875, 5);
|
||||
});
|
||||
|
||||
it("applies city-specific public holidays to SAH", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_augsburg",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_1", name: "Augsburg" },
|
||||
},
|
||||
{
|
||||
id: "resource_munich",
|
||||
eid: "E-002",
|
||||
displayName: "Bob",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_2",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_2", name: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2028-08",
|
||||
endMonth: "2028-08",
|
||||
});
|
||||
|
||||
const augsburg = report.resources.find((resource) => resource.city === "Augsburg");
|
||||
const munich = report.resources.find((resource) => resource.city === "Munich");
|
||||
|
||||
expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8);
|
||||
});
|
||||
|
||||
it("respects individual weekday availability when computing booked hours", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_pt",
|
||||
eid: "E-003",
|
||||
displayName: "Carla",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 0 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_3",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_3", name: "Berlin" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "project_week", utilizationCategory: { code: "Chg" } },
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_week",
|
||||
projectId: "project_week",
|
||||
resourceId: "resource_pt",
|
||||
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-06T00:00:00.000Z"),
|
||||
hoursPerDay: 4,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_week",
|
||||
name: "Week Project",
|
||||
shortCode: "WP",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_pt", displayName: "Carla", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2026-03",
|
||||
endMonth: "2026-03",
|
||||
});
|
||||
|
||||
const month = report.resources[0]?.months[0];
|
||||
|
||||
expect(month).toBeDefined();
|
||||
expect(month?.chg).toBeCloseTo(16 / 144, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computationGraphRouter } from "../router/computation-graph.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
const createCaller = createCallerFactory(computationGraphRouter);
|
||||
|
||||
type ResourceGraphMeta = {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resolvedHolidays: Array<{
|
||||
date: string;
|
||||
name: string;
|
||||
scope: "COUNTRY" | "STATE" | "CITY";
|
||||
calendarName: string | null;
|
||||
}>;
|
||||
factors: {
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayCount: number;
|
||||
absenceHoursDeduction: number;
|
||||
};
|
||||
};
|
||||
|
||||
function createControllerCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||
expires: "2026-03-14T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_controller",
|
||||
systemRole: SystemRole.CONTROLLER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createDb(resourceFindImpl: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
resource: {
|
||||
findUniqueOrThrow: resourceFindImpl,
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
calculationRule: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildResource(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "resource_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "bruce.banner",
|
||||
fte: 1,
|
||||
lcrCents: 5_000,
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
name: "Deutschland",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
metroCity: null,
|
||||
managementLevelGroup: {
|
||||
id: "mlg_1",
|
||||
name: "Senior",
|
||||
targetPercentage: 0.8,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computation graph router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exposes location context and city-local holidays in the resource graph", async () => {
|
||||
const db = createDb(vi.fn().mockResolvedValue(buildResource({
|
||||
id: "resource_augsburg",
|
||||
metroCityId: "city_augsburg",
|
||||
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||
})));
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getResourceData({
|
||||
resourceId: "resource_augsburg",
|
||||
month: "2026-08",
|
||||
});
|
||||
const meta = result.meta as ResourceGraphMeta;
|
||||
const nodeIds = result.nodes.map((node) => node.id);
|
||||
const holidayExamples = result.nodes.find((node) => node.id === "input.holidayExamples");
|
||||
|
||||
expect(new Set(nodeIds).size).toBe(nodeIds.length);
|
||||
expect(nodeIds).toEqual(expect.arrayContaining([
|
||||
"input.country",
|
||||
"input.state",
|
||||
"input.city",
|
||||
"input.holidayContext",
|
||||
"input.holidayExamples",
|
||||
"sah.baseHours",
|
||||
"sah.publicHolidayHours",
|
||||
"sah.absenceHours",
|
||||
]));
|
||||
expect(meta).toMatchObject({
|
||||
countryCode: "DE",
|
||||
countryName: "Deutschland",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
});
|
||||
expect(meta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
date: "2026-08-08",
|
||||
name: "Augsburger Friedensfest",
|
||||
scope: "CITY",
|
||||
}),
|
||||
]));
|
||||
expect(meta.factors.publicHolidayCount).toBeGreaterThan(0);
|
||||
expect(meta.factors.publicHolidayWorkdayCount).toBe(0);
|
||||
expect(holidayExamples?.value).toEqual(expect.stringContaining("Augsburger Friedensfest"));
|
||||
});
|
||||
|
||||
it("derives different effective SAH values for Bavaria and Hamburg", async () => {
|
||||
const db = createDb(vi.fn()
|
||||
.mockResolvedValueOnce(buildResource({
|
||||
id: "resource_by",
|
||||
federalState: "BY",
|
||||
managementLevelGroup: null,
|
||||
}))
|
||||
.mockResolvedValueOnce(buildResource({
|
||||
id: "resource_hh",
|
||||
federalState: "HH",
|
||||
managementLevelGroup: null,
|
||||
})));
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const bavaria = await caller.getResourceData({
|
||||
resourceId: "resource_by",
|
||||
month: "2026-01",
|
||||
});
|
||||
const hamburg = await caller.getResourceData({
|
||||
resourceId: "resource_hh",
|
||||
month: "2026-01",
|
||||
});
|
||||
|
||||
const bavariaMeta = bavaria.meta as ResourceGraphMeta;
|
||||
const hamburgMeta = hamburg.meta as ResourceGraphMeta;
|
||||
|
||||
expect(bavariaMeta.federalState).toBe("BY");
|
||||
expect(hamburgMeta.federalState).toBe("HH");
|
||||
expect(bavariaMeta.factors.baseAvailableHours).toBe(176);
|
||||
expect(hamburgMeta.factors.baseAvailableHours).toBe(176);
|
||||
expect(bavariaMeta.factors.effectiveAvailableHours).toBe(160);
|
||||
expect(hamburgMeta.factors.effectiveAvailableHours).toBe(168);
|
||||
expect(bavariaMeta.factors.publicHolidayWorkdayCount).toBe(2);
|
||||
expect(hamburgMeta.factors.publicHolidayWorkdayCount).toBe(1);
|
||||
expect(bavariaMeta.factors.publicHolidayHoursDeduction).toBe(16);
|
||||
expect(hamburgMeta.factors.publicHolidayHoursDeduction).toBe(8);
|
||||
expect(bavariaMeta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
|
||||
]));
|
||||
expect(hamburgMeta.resolvedHolidays).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
getDashboardDemand: vi.fn(),
|
||||
getDashboardTopValueResources: vi.fn(),
|
||||
getDashboardChargeabilityOverview: vi.fn(),
|
||||
getDashboardBudgetForecast: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
getDashboardDemand,
|
||||
getDashboardTopValueResources,
|
||||
getDashboardChargeabilityOverview,
|
||||
getDashboardBudgetForecast,
|
||||
} from "@capakraken/application";
|
||||
import { dashboardRouter } from "../router/dashboard.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
@@ -302,4 +304,52 @@ describe("dashboard router", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBudgetForecast", () => {
|
||||
it("returns budget forecast rows with calendar location context", async () => {
|
||||
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||
{
|
||||
projectId: "project_1",
|
||||
projectName: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
clientId: "client_1",
|
||||
clientName: "Client One",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 40_000,
|
||||
remainingCents: 60_000,
|
||||
burnRate: 10_000,
|
||||
estimatedExhaustionDate: "2026-06-30",
|
||||
pctUsed: 40,
|
||||
activeAssignmentCount: 2,
|
||||
calendarLocations: [
|
||||
{
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
activeAssignmentCount: 2,
|
||||
burnRateCents: 10_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller({});
|
||||
const result = await caller.getBudgetForecast();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
projectName: "Alpha",
|
||||
activeAssignmentCount: 2,
|
||||
calendarLocations: [
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,7 @@ describe("effortRule.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -180,6 +181,7 @@ describe("effortRule.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -212,6 +214,7 @@ describe("effortRule.update", () => {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -236,6 +239,7 @@ describe("effortRule.update", () => {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -281,6 +285,7 @@ describe("effortRule.delete", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
delete: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -16,7 +16,17 @@ const createCaller = createCallerFactory(entitlementRouter);
|
||||
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
const withResourceOwnership = {
|
||||
resource: { findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }) },
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.userId ? { userId: "user_1" } : {}),
|
||||
...(select.federalState ? { federalState: "BY" } : {}),
|
||||
...(select.country ? { country: { code: "DE" } } : {}),
|
||||
...(select.metroCity ? { metroCity: null } : {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
...db,
|
||||
};
|
||||
return createCaller({
|
||||
@@ -80,6 +90,14 @@ function sampleEntitlement(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function mockEntitlementFindUniqueByYear(
|
||||
entitlementsByYear: Record<number, ReturnType<typeof sampleEntitlement> | null>,
|
||||
) {
|
||||
return vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||
entitlementsByYear[where.resourceId_year.year] ?? null
|
||||
));
|
||||
}
|
||||
|
||||
// ─── getBalance ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("entitlement.getBalance", () => {
|
||||
@@ -90,7 +108,7 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
@@ -129,10 +147,9 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // current year not found
|
||||
.mockResolvedValueOnce(prevEntitlement), // previous year found
|
||||
findUnique: mockEntitlementFindUniqueByYear({
|
||||
2025: prevEntitlement,
|
||||
}),
|
||||
create: vi.fn().mockResolvedValue(createdEntitlement),
|
||||
update: vi.fn().mockResolvedValue(createdEntitlement),
|
||||
},
|
||||
@@ -164,7 +181,7 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
@@ -185,12 +202,14 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
// Public holiday vacations for holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// First call: balance-type vacations (for syncEntitlement)
|
||||
.mockResolvedValueOnce([])
|
||||
// Second call: sick days
|
||||
@@ -209,19 +228,169 @@ describe("entitlement.getBalance", () => {
|
||||
|
||||
expect(result.sickDays).toBe(3);
|
||||
});
|
||||
|
||||
it("does not deduct city-specific public holidays from leave balance", async () => {
|
||||
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
}),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
|
||||
update: vi.fn().mockImplementation(async ({ data }) => ({
|
||||
...entitlement,
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
|
||||
|
||||
expect(result.usedDays).toBe(0);
|
||||
expect(result.remainingDays).toBe(30);
|
||||
});
|
||||
|
||||
it("recomputes carryover from the previous year when the next year already exists", async () => {
|
||||
const entitlements = new Map([
|
||||
[2025, sampleEntitlement({
|
||||
id: "ent_2025",
|
||||
year: 2025,
|
||||
entitledDays: 28,
|
||||
carryoverDays: 0,
|
||||
usedDays: 8,
|
||||
pendingDays: 0,
|
||||
})],
|
||||
[2026, sampleEntitlement({
|
||||
id: "ent_2026",
|
||||
year: 2026,
|
||||
entitledDays: 28,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
})],
|
||||
]);
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||
entitlements.get(where.resourceId_year.year) ?? null
|
||||
)),
|
||||
create: vi.fn(),
|
||||
update: vi.fn().mockImplementation(async ({ where, data }: {
|
||||
where: { id: string };
|
||||
data: Record<string, number>;
|
||||
}) => {
|
||||
const current = [...entitlements.values()].find((entry) => entry.id === where.id);
|
||||
if (!current) {
|
||||
throw new Error(`Unknown entitlement ${where.id}`);
|
||||
}
|
||||
const updated = { ...current, ...data };
|
||||
entitlements.set(updated.year, updated);
|
||||
return updated;
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
// 2025 holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// 2025 balance vacations
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2025-06-10T00:00:00.000Z"),
|
||||
endDate: new Date("2025-06-17T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
},
|
||||
])
|
||||
// 2026 holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 balance vacations
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 sick days
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
|
||||
|
||||
expect(result.carryoverDays).toBe(20);
|
||||
expect(result.entitledDays).toBe(48);
|
||||
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "ent_2026" },
|
||||
data: expect.objectContaining({
|
||||
carryoverDays: 20,
|
||||
entitledDays: 48,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("entitlement.get", () => {
|
||||
it("returns existing entitlement (manager role)", async () => {
|
||||
const entitlement = sampleEntitlement();
|
||||
const entitlement = sampleEntitlement({
|
||||
entitledDays: 30,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
});
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, number> }) => ({
|
||||
...entitlement,
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -259,6 +428,7 @@ describe("entitlement.set", () => {
|
||||
update: vi.fn().mockResolvedValue(updated),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -286,6 +456,7 @@ describe("entitlement.set", () => {
|
||||
update: vi.fn(),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -324,6 +495,7 @@ describe("entitlement.bulkSet", () => {
|
||||
vacationEntitlement: {
|
||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
@@ -350,6 +522,7 @@ describe("entitlement.bulkSet", () => {
|
||||
vacationEntitlement: {
|
||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
@@ -396,10 +569,15 @@ describe("entitlement.getYearSummary", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
federalState: "BY",
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue(resources),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
|
||||
@@ -24,10 +24,12 @@ vi.mock("ioredis", () => {
|
||||
describe("event-bus debounce", () => {
|
||||
let received: SseEvent[];
|
||||
let unsubscribe: () => void;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
received = [];
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
unsubscribe = eventBus.subscribe((event) => {
|
||||
received.push(event);
|
||||
});
|
||||
@@ -36,6 +38,7 @@ describe("event-bus debounce", () => {
|
||||
afterEach(() => {
|
||||
unsubscribe();
|
||||
cancelPendingEvents();
|
||||
consoleWarnSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ describe("experienceMultiplier.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -203,6 +204,7 @@ describe("experienceMultiplier.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -235,6 +237,7 @@ describe("experienceMultiplier.update", () => {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -259,6 +262,7 @@ describe("experienceMultiplier.update", () => {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -308,6 +312,7 @@ describe("experienceMultiplier.delete", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
delete: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
import { holidayCalendarRouter } from "../router/holiday-calendar.js";
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(holidayCalendarRouter);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "user@example.com", name: "User", image: null },
|
||||
expires: "2026-12-31T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: SystemRole.USER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createAdminCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "admin@example.com", name: "Admin", image: null },
|
||||
expires: "2026-12-31T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "admin_1",
|
||||
systemRole: SystemRole.ADMIN,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("holiday calendar router", () => {
|
||||
it("merges built-in and scoped custom holidays in preview", async () => {
|
||||
const db = {
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_city",
|
||||
name: "Augsburg lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-01-01T00:00:00.000Z"),
|
||||
name: "Augsburg Neujahr",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
{
|
||||
date: new Date("2020-08-08T00:00:00.000Z"),
|
||||
name: "Friedensfest lokal",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewResolvedHolidays({
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_augsburg",
|
||||
year: 2026,
|
||||
});
|
||||
|
||||
expect(db.holidayCalendar.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
countryId: "country_de",
|
||||
isActive: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
date: "2026-01-01",
|
||||
name: "Augsburg Neujahr",
|
||||
scopeType: "CITY",
|
||||
calendarName: "Augsburg lokal",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: "2026-08-08",
|
||||
name: "Friedensfest lokal",
|
||||
scopeType: "CITY",
|
||||
calendarName: "Augsburg lokal",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate calendar scopes on create", async () => {
|
||||
const db = {
|
||||
country: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" })
|
||||
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" }),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_scope" }),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.createCalendar({
|
||||
name: "Deutschland Standard",
|
||||
scopeType: "COUNTRY",
|
||||
countryId: "country_de",
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
})).rejects.toThrow("A holiday calendar for this exact scope already exists");
|
||||
|
||||
expect(db.holidayCalendar.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects duplicate entry dates within the same calendar", async () => {
|
||||
const db = {
|
||||
holidayCalendar: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "cal_1", name: "Deutschland Standard" }),
|
||||
},
|
||||
holidayCalendarEntry: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.createEntry({
|
||||
holidayCalendarId: "cal_1",
|
||||
date: new Date("2026-12-24T00:00:00.000Z"),
|
||||
name: "Heiligabend lokal",
|
||||
isRecurringAnnual: true,
|
||||
source: "manual",
|
||||
})).rejects.toThrow("A holiday entry for this calendar and date already exists");
|
||||
|
||||
expect(db.holidayCalendarEntry.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -187,6 +187,7 @@ describe("notification.create", () => {
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
@@ -209,6 +210,7 @@ describe("notification.create", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.notification.findUnique).toHaveBeenCalledWith({ where: { id: "notif_1" } });
|
||||
});
|
||||
|
||||
it("creates a notification with optional fields", async () => {
|
||||
@@ -222,6 +224,7 @@ describe("notification.create", () => {
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
|
||||
@@ -134,12 +134,14 @@ describe("project router", () => {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
shortCode: "PRJ-001",
|
||||
name: "Test Project",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
winProbability: 80,
|
||||
@@ -167,6 +169,7 @@ describe("project router", () => {
|
||||
caller.create({
|
||||
shortCode: "PRJ-001",
|
||||
name: "Duplicate",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
budgetCents: 100_00,
|
||||
@@ -189,6 +192,7 @@ describe("project router", () => {
|
||||
caller.create({
|
||||
shortCode: "PRJ-002",
|
||||
name: "Blocked",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
budgetCents: 100_00,
|
||||
@@ -239,6 +243,64 @@ describe("project router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShoringRatio", () => {
|
||||
it("excludes regional holidays from shoring weighting", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Test Project",
|
||||
shoringThreshold: 55,
|
||||
onshoreCountryCode: "DE",
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
resourceId: "res_de",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
resource: {
|
||||
id: "res_de",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||
country: { id: "country_de", code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
resourceId: "res_es",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
resource: {
|
||||
id: "res_es",
|
||||
countryId: "country_es",
|
||||
federalState: null,
|
||||
metroCityId: null,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||
country: { id: "country_es", code: "ES" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getShoringRatio({ projectId: "project_1" });
|
||||
|
||||
expect(result.totalHours).toBe(24);
|
||||
expect(result.onshoreRatio).toBe(33);
|
||||
expect(result.offshoreRatio).toBe(67);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("update", () => {
|
||||
@@ -294,6 +356,7 @@ describe("project router", () => {
|
||||
project: {
|
||||
update: vi.fn().mockResolvedValue(updated),
|
||||
},
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
isChargeabilityActualBooking: vi.fn(() => false),
|
||||
isChargeabilityRelevantProject: vi.fn(() => false),
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/resource-capacity.js", () => ({
|
||||
calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 156 : 168)),
|
||||
calculateEffectiveBookedHours: vi.fn(() => 0),
|
||||
countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 19.5 : 21)),
|
||||
getAvailabilityHoursForDate: vi.fn(() => 8),
|
||||
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map([
|
||||
[
|
||||
"res_1",
|
||||
{
|
||||
holidayDates: new Set(["2026-04-10"]),
|
||||
vacationFractionsByDate: new Map([["2026-04-14", 0.5]]),
|
||||
},
|
||||
],
|
||||
])),
|
||||
}));
|
||||
|
||||
import { reportRouter } from "../router/report.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
const createCaller = createCallerFactory(reportRouter);
|
||||
|
||||
function createControllerCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_controller",
|
||||
systemRole: SystemRole.CONTROLLER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("report router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists the new resource month transparency columns", async () => {
|
||||
const caller = createControllerCaller({});
|
||||
const columns = await caller.getAvailableColumns({ entity: "resource_month" });
|
||||
|
||||
expect(columns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "monthlyPublicHolidayCount", label: "Holiday Dates" }),
|
||||
expect.objectContaining({ key: "monthlyTargetHours", label: "Target Hours" }),
|
||||
expect.objectContaining({ key: "monthlyUnassignedHours", label: "Unassigned Hours" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("exports resource month basis and computed columns in CSV", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
eid: "alice",
|
||||
displayName: "Alice",
|
||||
email: "alice@example.com",
|
||||
chapter: "VFX",
|
||||
resourceType: "EMPLOYEE",
|
||||
isActive: true,
|
||||
chgResponsibility: false,
|
||||
rolledOff: false,
|
||||
departed: false,
|
||||
lcrCents: 7500,
|
||||
ucrCents: 10000,
|
||||
currency: "EUR",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: null,
|
||||
orgUnit: { name: "Delivery" },
|
||||
managementLevelGroup: null,
|
||||
managementLevel: { name: "Senior" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.exportReport({
|
||||
entity: "resource_month",
|
||||
columns: [
|
||||
"displayName",
|
||||
"countryCode",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyTargetHours",
|
||||
"monthlyUnassignedHours",
|
||||
],
|
||||
filters: [],
|
||||
periodMonth: "2026-04",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
|
||||
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,10 @@ describe("resource router", () => {
|
||||
valueScoreBreakdown: null,
|
||||
valueScoreUpdatedAt: null,
|
||||
userId: null,
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -158,6 +162,165 @@ describe("resource router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates utilization with regional holidays removed from available hours", async () => {
|
||||
const resource = {
|
||||
id: "resource_1",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
email: "alice@example.com",
|
||||
chapter: "CGI",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 9000,
|
||||
currency: "EUR",
|
||||
chargeabilityTarget: 80,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
skills: [],
|
||||
dynamicFields: {},
|
||||
blueprintId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-03-01"),
|
||||
updatedAt: new Date("2026-03-01"),
|
||||
roleId: null,
|
||||
portfolioUrl: null,
|
||||
postalCode: null,
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
valueScore: null,
|
||||
valueScoreBreakdown: null,
|
||||
valueScoreUpdatedAt: null,
|
||||
userId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([resource]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_confirmed",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project 1",
|
||||
shortCode: "P1",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.listWithUtilization({
|
||||
startDate: "2026-01-05T00:00:00.000Z",
|
||||
endDate: "2026-01-06T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
bookingCount: 1,
|
||||
bookedHours: 8,
|
||||
availableHours: 8,
|
||||
utilizationPercent: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("shifts marketplace availability when a local holiday blocks today", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-06T10:00:00.000Z"));
|
||||
|
||||
try {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
displayName: "Bavaria Artist",
|
||||
eid: "E-BY",
|
||||
chapter: "CGI",
|
||||
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
{
|
||||
id: "resource_hh",
|
||||
displayName: "Hamburg Artist",
|
||||
eid: "E-HH",
|
||||
chapter: "CGI",
|
||||
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getSkillMarketplace({
|
||||
searchSkill: "houdini",
|
||||
availableOnly: true,
|
||||
});
|
||||
|
||||
const bavaria = result.searchResults.find((resource) => resource.id === "resource_by");
|
||||
const hamburg = result.searchResults.find((resource) => resource.id === "resource_hh");
|
||||
|
||||
expect(bavaria?.availableFrom).toBe("2026-01-07T00:00:00.000Z");
|
||||
expect(hamburg?.availableFrom).toBe("2026-01-06T00:00:00.000Z");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a composite displayName/id cursor for stable pagination", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -314,6 +477,84 @@ describe("resource router", () => {
|
||||
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from chargeability stats", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
eid: "E-BY",
|
||||
displayName: "Bavaria",
|
||||
chapter: "CGI",
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_holiday",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_by",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project 1",
|
||||
shortCode: "P1",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_by", displayName: "Bavaria", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const RealDate = Date;
|
||||
class MockDate extends Date {
|
||||
constructor(...args: ConstructorParameters<typeof Date>) {
|
||||
if (args.length === 0) {
|
||||
super("2026-01-15T00:00:00.000Z");
|
||||
return;
|
||||
}
|
||||
super(...args);
|
||||
}
|
||||
static now() {
|
||||
return new RealDate("2026-01-15T00:00:00.000Z").getTime();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Date", MockDate);
|
||||
|
||||
try {
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getChargeabilityStats({});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
actualChargeability: 0,
|
||||
expectedChargeability: 0,
|
||||
availableHours: 168,
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies country filters including explicit no-country toggle", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
|
||||
@@ -17,23 +17,6 @@ vi.mock("@capakraken/staffing", () => ({
|
||||
},
|
||||
})),
|
||||
),
|
||||
analyzeUtilization: vi.fn().mockReturnValue({
|
||||
resourceId: "res_1",
|
||||
displayName: "Alice",
|
||||
totalDays: 20,
|
||||
allocatedDays: 15,
|
||||
utilizationPercent: 75,
|
||||
chargeablePercent: 60,
|
||||
overallocatedDays: 0,
|
||||
dailyBreakdown: [],
|
||||
}),
|
||||
findCapacityWindows: vi.fn().mockReturnValue([
|
||||
{
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-10"),
|
||||
availableHoursPerDay: 6,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
@@ -76,6 +59,11 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
|
||||
isActive: true,
|
||||
valueScore: 85,
|
||||
chapter: "VFX",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -105,6 +93,30 @@ describe("staffing.getSuggestions", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("resourceId");
|
||||
expect(result[0]).toHaveProperty("score");
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceName: "Alice",
|
||||
eid: "alice",
|
||||
location: {
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
},
|
||||
capacity: expect.objectContaining({
|
||||
requestedHoursPerDay: 8,
|
||||
baseAvailableHours: expect.any(Number),
|
||||
effectiveAvailableHours: expect.any(Number),
|
||||
remainingHoursPerDay: expect.any(Number),
|
||||
holidayHoursDeduction: expect.any(Number),
|
||||
}),
|
||||
conflicts: {
|
||||
count: expect.any(Number),
|
||||
conflictDays: expect.any(Array),
|
||||
details: expect.any(Array),
|
||||
},
|
||||
ranking: expect.objectContaining({
|
||||
rank: 1,
|
||||
components: expect.any(Array),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("filters resources by chapter when provided", async () => {
|
||||
@@ -175,6 +187,58 @@ describe("staffing.getSuggestions", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses value score as a transparent tiebreaker within two score points", async () => {
|
||||
const resources = [
|
||||
sampleResource({ id: "res_1", displayName: "Alice", eid: "alice", valueScore: 60 }),
|
||||
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 95 }),
|
||||
];
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue(resources),
|
||||
},
|
||||
};
|
||||
|
||||
const { rankResources } = await import("@capakraken/staffing");
|
||||
vi.mocked(rankResources).mockImplementationOnce((input: { resources: Array<{ id: string }> }) => ([
|
||||
{
|
||||
resourceId: input.resources[0]!.id,
|
||||
score: 80,
|
||||
breakdown: {
|
||||
skillScore: 80,
|
||||
availabilityScore: 80,
|
||||
costScore: 80,
|
||||
utilizationScore: 80,
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: input.resources[1]!.id,
|
||||
score: 79,
|
||||
breakdown: {
|
||||
skillScore: 79,
|
||||
availabilityScore: 79,
|
||||
costScore: 79,
|
||||
utilizationScore: 79,
|
||||
},
|
||||
},
|
||||
]));
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getSuggestions({
|
||||
requiredSkills: ["Compositing"],
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
hoursPerDay: 8,
|
||||
});
|
||||
|
||||
expect(result[0]?.resourceId).toBe("res_2");
|
||||
expect(result[0]?.ranking).toMatchObject({
|
||||
rank: 1,
|
||||
baseRank: 2,
|
||||
tieBreakerApplied: true,
|
||||
});
|
||||
expect(result[0]?.ranking.tieBreakerReason).toContain("value score");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── analyzeUtilization ──────────────────────────────────────────────────────
|
||||
@@ -186,6 +250,11 @@ describe("staffing.analyzeUtilization", () => {
|
||||
displayName: "Alice",
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -200,10 +269,56 @@ describe("staffing.analyzeUtilization", () => {
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("utilizationPercent");
|
||||
expect(result).toHaveProperty("currentChargeability");
|
||||
expect(result.resourceId).toBe("res_1");
|
||||
});
|
||||
|
||||
it("excludes Bavarian public holidays from chargeability analysis", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(resource),
|
||||
},
|
||||
};
|
||||
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_1", name: "Chargeable", shortCode: "CHG", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.analyzeUtilization({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.currentChargeability).toBe(100);
|
||||
expect(result.overallocatedDays).toEqual([]);
|
||||
expect(result.underutilizedDays).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -230,6 +345,11 @@ describe("staffing.findCapacity", () => {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -244,8 +364,53 @@ describe("staffing.findCapacity", () => {
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("availableHoursPerDay");
|
||||
expect(result.every((window) => window.availableHoursPerDay > 0)).toBe(true);
|
||||
expect(result.reduce((sum, window) => sum + window.availableDays, 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("splits capacity windows around Bavarian public holidays", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(resource),
|
||||
},
|
||||
};
|
||||
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.findCapacity({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
minAvailableHoursPerDay: 4,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||
@@ -265,11 +430,16 @@ describe("staffing.findCapacity", () => {
|
||||
).rejects.toThrow("Resource not found");
|
||||
});
|
||||
|
||||
it("passes minAvailableHoursPerDay to engine", async () => {
|
||||
it("honors minAvailableHoursPerDay when computing holiday-aware windows", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -277,21 +447,30 @@ describe("staffing.findCapacity", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const { findCapacityWindows } = await import("@capakraken/staffing");
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||
hoursPerDay: 3,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_1", name: "Project", shortCode: "PRJ", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await caller.findCapacity({
|
||||
const result = await caller.findCapacity({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
minAvailableHoursPerDay: 6,
|
||||
});
|
||||
|
||||
expect(findCapacityWindows).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
6,
|
||||
);
|
||||
expect(result.every((window) => window.availableHoursPerDay >= 6)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,4 +290,83 @@ describe("timeline allocation entry resolution", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns resolved holiday overlays for assigned resources", async () => {
|
||||
const db = {
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
kind: "assignment",
|
||||
resourceId: "resource_by",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-01-31"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
metadata: {},
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project One",
|
||||
shortCode: "PRJ",
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-03-31"),
|
||||
orderType: "CHARGEABLE",
|
||||
clientId: null,
|
||||
},
|
||||
resource: {
|
||||
id: "resource_by",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
chapter: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
country: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const overlays = await caller.getHolidayOverlays({
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-01-31"),
|
||||
});
|
||||
|
||||
expect(overlays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: "resource_by",
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
note: "Heilige Drei Könige",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,30 @@ vi.mock("../sse/event-bus.js", () => ({
|
||||
emitVacationUpdated: vi.fn(),
|
||||
emitVacationDeleted: vi.fn(),
|
||||
emitNotificationCreated: vi.fn(),
|
||||
emitTaskAssigned: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/email.js", () => ({
|
||||
sendEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/create-notification.js", () => ({
|
||||
createNotification: vi.fn().mockResolvedValue("notif_1"),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/vacation-conflicts.js", () => ({
|
||||
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
|
||||
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(vacationRouter);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
@@ -91,6 +109,56 @@ const sampleVacation = {
|
||||
approvedBy: null,
|
||||
};
|
||||
|
||||
function createVacationDb(overrides: Record<string, unknown> = {}) {
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.userId ? { userId: "user_1" } : {}),
|
||||
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||
...(select.user ? { user: null } : {}),
|
||||
...(select.federalState ? { federalState: "BY" } : {}),
|
||||
...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
|
||||
...(select.metroCity ? { metroCity: null } : {}),
|
||||
};
|
||||
}),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
notification: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...db,
|
||||
...overrides,
|
||||
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||
resource: { ...db.resource, ...(overrides.resource as Record<string, unknown> | undefined) },
|
||||
vacation: { ...db.vacation, ...(overrides.vacation as Record<string, unknown> | undefined) },
|
||||
notification: {
|
||||
...db.notification,
|
||||
...(overrides.notification as Record<string, unknown> | undefined),
|
||||
},
|
||||
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||
};
|
||||
}
|
||||
|
||||
describe("vacation router", () => {
|
||||
describe("list", () => {
|
||||
it("returns vacations with default filters", async () => {
|
||||
@@ -199,18 +267,11 @@ describe("vacation router", () => {
|
||||
status: VacationStatus.PENDING,
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -239,15 +300,14 @@ describe("vacation router", () => {
|
||||
approvedById: "mgr_1",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -269,17 +329,11 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("rejects overlapping vacation", async () => {
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
@@ -293,10 +347,10 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("rejects when end date is before start date", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: { findUnique: vi.fn() },
|
||||
vacation: { findFirst: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
@@ -316,18 +370,11 @@ describe("vacation router", () => {
|
||||
halfDayPart: "MORNING",
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -349,6 +396,235 @@ describe("vacation router", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multi-day half-day vacations", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-02"),
|
||||
isHalfDay: true,
|
||||
halfDayPart: "MORNING",
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects half-day vacations without a half-day part", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
isHalfDay: true,
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects half-day parts on full-day vacations", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
halfDayPart: "AFTERNOON",
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects leave requests that only hit public holidays", async () => {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
})).rejects.toThrow("does not deduct any vacation days");
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRequest", () => {
|
||||
it("shows public holidays as non-deductible leave days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.requestedDays).toBe(1);
|
||||
expect(result.effectiveDays).toBe(0);
|
||||
expect(result.deductedDays).toBe(0);
|
||||
expect(result.publicHolidayDates).toContain("2028-08-08");
|
||||
expect(result.holidayContext).toEqual({
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
sources: {
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
});
|
||||
expect(result.holidayDetails).toContainEqual({
|
||||
date: "2028-08-08",
|
||||
source: "CALENDAR",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses custom city holiday calendars for non-deductible leave days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_muc",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_muc",
|
||||
name: "Muenchen lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-11-15T00:00:00.000Z"),
|
||||
name: "Lokaler Stadtfeiertag",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.requestedDays).toBe(1);
|
||||
expect(result.effectiveDays).toBe(0);
|
||||
expect(result.publicHolidayDates).toContain("2026-11-15");
|
||||
expect(result.holidayContext.countryName).toBe("Germany");
|
||||
expect(result.holidayContext.metroCityName).toBe("Muenchen");
|
||||
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks legacy public holiday entries as a separate preview source", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "HH",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.publicHolidayDates).toContain("2026-05-01");
|
||||
expect(result.holidayContext.sources).toEqual({
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: true,
|
||||
});
|
||||
expect(result.holidayDetails).toContainEqual({
|
||||
date: "2026-05-01",
|
||||
source: "CALENDAR_AND_LEGACY",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multi-day half-day previews", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-02"),
|
||||
isHalfDay: true,
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create manual public holiday handling", () => {
|
||||
it("rejects manual public holiday creation requests", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
})).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("approve", () => {
|
||||
@@ -359,7 +635,7 @@ describe("vacation router", () => {
|
||||
approvedById: "mgr_1",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
@@ -370,7 +646,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.approve({ id: "vac_1" });
|
||||
@@ -388,25 +664,25 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for missing vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||
});
|
||||
|
||||
it("rejects approving an already APPROVED vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
|
||||
@@ -429,7 +705,7 @@ describe("vacation router", () => {
|
||||
rejectionReason: "Team conflict",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
@@ -437,7 +713,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
|
||||
@@ -454,14 +730,14 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws when rejecting non-PENDING vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
|
||||
@@ -477,15 +753,12 @@ describe("vacation router", () => {
|
||||
status: VacationStatus.CANCELLED,
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.cancel({ id: "vac_1" });
|
||||
@@ -494,25 +767,25 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for missing vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||
});
|
||||
|
||||
it("throws when already cancelled", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.CANCELLED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
|
||||
@@ -521,7 +794,7 @@ describe("vacation router", () => {
|
||||
|
||||
describe("batchApprove", () => {
|
||||
it("approves multiple pending vacations", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
@@ -535,7 +808,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
|
||||
@@ -552,7 +825,7 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("only approves PENDING vacations from the requested set", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
@@ -565,7 +838,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
|
||||
@@ -581,7 +854,10 @@ describe("vacation router", () => {
|
||||
|
||||
describe("batchReject", () => {
|
||||
it("rejects multiple pending vacations with optional reason", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "vac_1", resourceId: "res_1" },
|
||||
@@ -591,7 +867,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchReject({
|
||||
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "res_1" },
|
||||
{ id: "res_2" },
|
||||
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
{ id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
]),
|
||||
},
|
||||
user: {
|
||||
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
|
||||
it("skips already existing holidays", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
]),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
|
||||
|
||||
Reference in New Issue
Block a user