feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
@@ -1,7 +1,12 @@
import { AllocationStatus, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { allocationRouter } from "../router/allocation.js";
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
import { checkBudgetThresholds } from "../lib/budget-alerts.js";
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
@@ -19,12 +24,29 @@ vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn(),
}));
vi.mock("../lib/auto-staffing.js", () => ({
generateAutoSuggestions: vi.fn(),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(allocationRouter);
beforeEach(() => {
vi.clearAllMocks();
});
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
@@ -112,6 +134,9 @@ describe("allocation entry resolution router", () => {
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
@@ -134,6 +159,97 @@ describe("allocation entry resolution router", () => {
});
});
it("returns the canonical resource availability summary shape", 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-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-01T00:00:00.000Z"),
hoursPerDay: 4,
status: "CONFIRMED",
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
type: "ANNUAL",
status: "APPROVED",
startDate: new Date("2026-04-02T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
isHalfDay: true,
halfDayPart: "AFTERNOON",
},
]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getResourceAvailabilitySummary({
resourceId: "resource_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
hoursPerDay: 8,
});
expect(result).toEqual({
resource: "Bruce Banner",
period: "2026-04-01 to 2026-04-02",
fte: null,
workingDays: 2,
periodAvailableHours: 16,
periodBookedHours: 4,
periodRemainingHours: 12,
maxHoursPerDay: 8,
currentBookedHoursPerDay: 2,
availableHoursPerDay: 6,
isFullyAvailable: false,
existingAllocations: [
{
project: "Gelddruckmaschine (GDM)",
hoursPerDay: 4,
status: "CONFIRMED",
start: "2026-04-01",
end: "2026-04-01",
},
],
vacations: [
{
type: "ANNUAL",
start: "2026-04-02",
end: "2026-04-02",
isHalfDay: true,
},
],
});
});
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
const createdDemandRequirement = {
id: "demand_1",
@@ -346,6 +462,217 @@ describe("allocation entry resolution router", () => {
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
});
it("creates a canonical demand draft with router-owned defaults", async () => {
vi.mocked(emitAllocationCreated).mockClear();
vi.mocked(emitNotificationCreated).mockClear();
const createdDemandRequirement = {
id: "demand_draft_1",
projectId: "project_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_design",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_design", name: "Designer", color: "#0099FF" },
};
const db = createDemandWorkflowDb({
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
}) 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.createDemand({
projectId: "project_1",
role: "Designer",
roleId: "role_design",
headcount: 2,
hoursPerDay: 6,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
});
expect(result.id).toBe("demand_draft_1");
expect(db.demandRequirement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
headcount: 2,
percentage: 75,
status: AllocationStatus.PROPOSED,
metadata: {},
}),
}),
);
expect(emitAllocationCreated).toHaveBeenCalledWith({
id: "demand_draft_1",
projectId: "project_1",
resourceId: null,
});
});
it("logs and swallows background side-effect failures during demand creation", async () => {
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
vi.mocked(checkBudgetThresholds).mockRejectedValueOnce(new Error("budget alerts unavailable"));
vi.mocked(generateAutoSuggestions).mockRejectedValueOnce(new Error("auto suggestions unavailable"));
const createdDemandRequirement = {
id: "demand_safe_1",
projectId: "project_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_design",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_design", name: "Designer", color: "#0099FF" },
};
const db = createDemandWorkflowDb({
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
}) 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.createDemand({
projectId: "project_1",
role: "Designer",
roleId: "role_design",
headcount: 2,
hoursPerDay: 6,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
});
await Promise.resolve();
await Promise.resolve();
expect(result.id).toBe("demand_safe_1");
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
"Allocation background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "checkBudgetThresholds", projectId: "project_1" }),
"Allocation background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "generateAutoSuggestions", demandRequirementId: "demand_safe_1" }),
"Allocation background side effect failed",
);
});
it("logs and swallows background webhook failures during allocation creation", async () => {
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
const createdAssignment = {
id: "assignment_safe_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
status: AllocationStatus.ACTIVE,
metadata: {},
});
await Promise.resolve();
await Promise.resolve();
expect(result.id).toBe("assignment_safe_1");
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "allocation.created" }),
"Allocation background side effect failed",
);
});
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
@@ -442,6 +769,121 @@ describe("allocation entry resolution router", () => {
});
});
it("assigns a resource to demand and returns the hydrated demand view", async () => {
const demandView = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-15T00:00:00.000Z"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_1",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" },
assignments: [],
};
const createdAssignment = {
id: "assignment_1",
demandRequirementId: "demand_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-15T00:00:00.000Z"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_1",
dailyCostCents: 42000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 7000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" },
demandRequirement: demandView,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 7000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
demandRequirement: {
findUnique: vi.fn()
.mockResolvedValueOnce({
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-15T00:00:00.000Z"),
hoursPerDay: 6,
role: "Designer",
roleId: "role_1",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
})
.mockResolvedValueOnce({
id: "demand_1",
projectId: "project_1",
})
.mockResolvedValueOnce(demandView),
update: vi.fn().mockResolvedValue({
id: "demand_1",
projectId: "project_1",
headcount: 1,
status: AllocationStatus.COMPLETED,
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.assignResourceToDemand({
demandRequirementId: "demand_1",
resourceId: "resource_1",
});
expect(result.assignment.id).toBe("assignment_1");
expect(result.demandRequirement.project.shortCode).toBe("PRJ");
expect(result.demandRequirement.roleEntity?.name).toBe("Designer");
expect(db.assignment.create).toHaveBeenCalledTimes(1);
});
it("deletes an explicit demand requirement without routing through allocation.delete", async () => {
vi.mocked(emitAllocationDeleted).mockClear();
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import { apiRateLimiter } from "../middleware/rate-limit.js";
import {
ASSISTANT_CONFIRMATION_PREFIX,
canExecuteMutationTool,
@@ -9,6 +10,7 @@ import {
getAvailableAssistantTools,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
selectAssistantToolsForRequest,
} from "../router/assistant.js";
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
@@ -19,6 +21,19 @@ function getToolNames(
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
}
function getSelectedToolNames(
permissions: PermissionKeyValue[],
messages: Array<{ role: "user" | "assistant"; content: string }>,
userRole: SystemRole = SystemRole.ADMIN,
pageContext?: string,
) {
return selectAssistantToolsForRequest(
getAvailableAssistantTools(new Set(permissions), userRole),
messages,
pageContext,
).map((tool) => tool.function.name);
}
const TEST_USER_ID = "assistant-test-user";
const TEST_CONVERSATION_ID = "assistant-test-conversation";
@@ -174,11 +189,22 @@ function createApprovalStoreMock() {
};
}
function createMissingApprovalTableError() {
return Object.assign(
new Error("The table `public.assistant_approvals` does not exist in the current database."),
{
code: "P2021",
meta: { table: "public.assistant_approvals" },
},
);
}
describe("assistant router tool gating", () => {
let approvalStore = createApprovalStoreMock();
beforeEach(() => {
approvalStore = createApprovalStoreMock();
apiRateLimiter.reset();
});
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
@@ -195,12 +221,115 @@ describe("assistant router tool gating", () => {
expect(withAdvanced).toContain("get_project_computation_graph");
});
it("keeps user administration tools behind manageUsers", () => {
const withoutManageUsers = getToolNames([]);
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
it("keeps user self-service tools available to plain authenticated users", () => {
const userNames = getToolNames([], SystemRole.USER);
expect(withoutManageUsers).not.toContain("list_users");
expect(withManageUsers).toContain("list_users");
expect(userNames).toContain("get_current_user");
expect(userNames).toContain("get_dashboard_layout");
expect(userNames).toContain("save_dashboard_layout");
expect(userNames).toContain("get_favorite_project_ids");
expect(userNames).toContain("toggle_favorite_project");
expect(userNames).toContain("get_column_preferences");
expect(userNames).toContain("set_column_preferences");
expect(userNames).toContain("get_mfa_status");
expect(userNames).toContain("list_notifications");
expect(userNames).toContain("get_unread_notification_count");
expect(userNames).toContain("list_tasks");
expect(userNames).toContain("get_task_counts");
expect(userNames).toContain("create_reminder");
expect(userNames).toContain("list_reminders");
expect(userNames).toContain("update_reminder");
expect(userNames).toContain("delete_reminder");
});
it("keeps admin-only user tools hidden from non-admin roles", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER);
expect(adminNames).toContain("list_users");
expect(adminNames).toContain("get_active_user_count");
expect(adminNames).toContain("create_user");
expect(adminNames).toContain("set_user_password");
expect(adminNames).toContain("update_user_role");
expect(adminNames).toContain("update_user_name");
expect(adminNames).toContain("link_user_resource");
expect(adminNames).toContain("auto_link_users_by_email");
expect(adminNames).toContain("set_user_permissions");
expect(adminNames).toContain("reset_user_permissions");
expect(adminNames).toContain("get_effective_user_permissions");
expect(adminNames).toContain("disable_user_totp");
expect(managerNames).not.toContain("list_users");
expect(managerNames).not.toContain("create_user");
expect(managerNames).not.toContain("set_user_permissions");
expect(managerNames).not.toContain("disable_user_totp");
expect(userNames).not.toContain("list_users");
expect(userNames).not.toContain("get_active_user_count");
expect(userNames).not.toContain("create_user");
expect(userNames).not.toContain("set_user_password");
expect(userNames).not.toContain("update_user_role");
expect(userNames).not.toContain("update_user_name");
expect(userNames).not.toContain("link_user_resource");
expect(userNames).not.toContain("auto_link_users_by_email");
expect(userNames).not.toContain("set_user_permissions");
expect(userNames).not.toContain("reset_user_permissions");
expect(userNames).not.toContain("get_effective_user_permissions");
expect(userNames).not.toContain("disable_user_totp");
});
it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => {
const allPermissions = Object.values(PermissionKey);
const selectedNames = getSelectedToolNames(
allPermissions,
[{ role: "user", content: "Bitte gib mir einen Überblick über das System." }],
SystemRole.ADMIN,
);
expect(selectedNames.length).toBeLessThanOrEqual(128);
expect(selectedNames).toContain("get_current_user");
expect(selectedNames).toContain("search_resources");
expect(selectedNames).toContain("search_projects");
});
it("prioritizes holiday and resource tools for German holiday questions", () => {
const allPermissions = Object.values(PermissionKey);
const selectedNames = getSelectedToolNames(
allPermissions,
[{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }],
SystemRole.ADMIN,
);
expect(selectedNames.length).toBeLessThanOrEqual(128);
expect(selectedNames).toContain("search_resources");
expect(selectedNames).toContain("get_resource");
expect(selectedNames).toContain("get_resource_holidays");
expect(selectedNames).toContain("list_holidays_by_region");
expect(selectedNames).toContain("list_holiday_calendars");
});
it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => {
const managerNames = getToolNames([], SystemRole.MANAGER);
const adminNames = getToolNames([], SystemRole.ADMIN);
const userNames = getToolNames([], SystemRole.USER);
expect(managerNames).toContain("list_assignable_users");
expect(managerNames).toContain("create_notification");
expect(managerNames).toContain("create_task_for_user");
expect(managerNames).toContain("assign_task");
expect(managerNames).toContain("send_broadcast");
expect(managerNames).toContain("list_broadcasts");
expect(managerNames).toContain("get_broadcast_detail");
expect(adminNames).toContain("list_assignable_users");
expect(adminNames).toContain("create_task_for_user");
expect(adminNames).toContain("send_broadcast");
expect(userNames).not.toContain("list_assignable_users");
expect(userNames).not.toContain("create_notification");
expect(userNames).not.toContain("create_task_for_user");
expect(userNames).not.toContain("assign_task");
expect(userNames).not.toContain("send_broadcast");
expect(userNames).not.toContain("list_broadcasts");
expect(userNames).not.toContain("get_broadcast_detail");
});
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
@@ -273,6 +402,66 @@ describe("assistant router tool gating", () => {
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
});
it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => {
const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER);
const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER);
const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER);
expect(managerProjectNames).toContain("create_estimate");
expect(managerProjectNames).toContain("clone_estimate");
expect(managerProjectNames).toContain("update_estimate_draft");
expect(managerProjectNames).toContain("submit_estimate_version");
expect(managerProjectNames).toContain("approve_estimate_version");
expect(managerProjectNames).toContain("create_estimate_revision");
expect(managerProjectNames).toContain("create_estimate_export");
expect(managerProjectNames).toContain("generate_estimate_weekly_phasing");
expect(managerProjectNames).toContain("update_estimate_commercial_terms");
expect(managerProjectNames).not.toContain("create_estimate_planning_handoff");
expect(managerAllocationNames).toContain("create_estimate_planning_handoff");
expect(managerAllocationNames).not.toContain("create_estimate");
expect(userProjectNames).not.toContain("create_estimate");
expect(userProjectNames).not.toContain("clone_estimate");
expect(userProjectNames).not.toContain("update_estimate_draft");
expect(userProjectNames).not.toContain("submit_estimate_version");
expect(userProjectNames).not.toContain("approve_estimate_version");
expect(userProjectNames).not.toContain("create_estimate_revision");
expect(userProjectNames).not.toContain("create_estimate_export");
expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing");
expect(userProjectNames).not.toContain("update_estimate_commercial_terms");
expect(userProjectNames).not.toContain("create_estimate_planning_handoff");
});
it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => {
const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER);
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
expect(controllerNames).toContain("get_estimate_detail");
expect(controllerNames).toContain("list_estimate_versions");
expect(controllerNames).toContain("get_estimate_version_snapshot");
expect(controllerNames).toContain("get_estimate_weekly_phasing");
expect(controllerNames).toContain("get_estimate_commercial_terms");
expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
expect(controllerWithoutCosts).toContain("list_estimate_versions");
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
expect(managerNames).toContain("get_estimate_detail");
expect(managerNames).toContain("list_estimate_versions");
expect(managerNames).toContain("get_estimate_version_snapshot");
expect(managerNames).toContain("get_estimate_weekly_phasing");
expect(managerNames).toContain("get_estimate_commercial_terms");
expect(managerWithoutCosts).toContain("list_estimate_versions");
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
expect(userNames).not.toContain("get_estimate_detail");
expect(userNames).not.toContain("list_estimate_versions");
expect(userNames).not.toContain("get_estimate_version_snapshot");
expect(userNames).not.toContain("get_estimate_weekly_phasing");
expect(userNames).not.toContain("get_estimate_commercial_terms");
});
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
@@ -284,11 +473,54 @@ describe("assistant router tool gating", () => {
expect(controllerNames).toContain("export_projects_csv");
expect(adminNames).toContain("list_dispo_import_batches");
expect(adminNames).toContain("get_dispo_import_batch");
expect(adminNames).toContain("stage_dispo_import_batch");
expect(adminNames).toContain("validate_dispo_import_batch");
expect(adminNames).toContain("cancel_dispo_import_batch");
expect(adminNames).toContain("list_dispo_staged_resources");
expect(adminNames).toContain("list_dispo_staged_projects");
expect(adminNames).toContain("list_dispo_staged_assignments");
expect(adminNames).toContain("list_dispo_staged_vacations");
expect(adminNames).toContain("list_dispo_staged_unresolved_records");
expect(adminNames).toContain("resolve_dispo_staged_record");
expect(adminNames).toContain("commit_dispo_import_batch");
expect(userNames).not.toContain("import_csv_data");
expect(userNames).not.toContain("export_resources_csv");
expect(userNames).not.toContain("export_projects_csv");
expect(userNames).not.toContain("list_dispo_import_batches");
expect(userNames).not.toContain("get_dispo_import_batch");
expect(userNames).not.toContain("stage_dispo_import_batch");
expect(userNames).not.toContain("validate_dispo_import_batch");
expect(userNames).not.toContain("list_dispo_staged_resources");
expect(userNames).not.toContain("commit_dispo_import_batch");
});
it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const userNames = getToolNames([], SystemRole.USER);
expect(adminNames).toContain("get_system_settings");
expect(adminNames).toContain("update_system_settings");
expect(adminNames).toContain("test_ai_connection");
expect(adminNames).toContain("test_smtp_connection");
expect(adminNames).toContain("test_gemini_connection");
expect(adminNames).toContain("update_system_role_config");
expect(adminNames).toContain("list_webhooks");
expect(adminNames).toContain("get_webhook");
expect(adminNames).toContain("create_webhook");
expect(adminNames).toContain("update_webhook");
expect(adminNames).toContain("delete_webhook");
expect(adminNames).toContain("test_webhook");
expect(adminNames).toContain("get_ai_configured");
expect(adminNames).toContain("list_system_role_configs");
expect(userNames).not.toContain("get_system_settings");
expect(userNames).not.toContain("update_system_settings");
expect(userNames).not.toContain("test_ai_connection");
expect(userNames).not.toContain("update_system_role_config");
expect(userNames).not.toContain("list_webhooks");
expect(userNames).not.toContain("create_webhook");
expect(userNames).toContain("get_ai_configured");
expect(userNames).toContain("list_system_role_configs");
});
it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
@@ -506,6 +738,59 @@ describe("assistant router tool gating", () => {
expect(approvalSummaries).not.toContain("Foreign");
});
it("degrades approval reads gracefully when approval storage is missing", async () => {
const missingTableError = createMissingApprovalTableError();
const missingStore = {
assistantApproval: {
findFirst: vi.fn(async () => {
throw missingTableError;
}),
findMany: vi.fn(async () => {
throw missingTableError;
}),
create: vi.fn(async () => {
throw missingTableError;
}),
updateMany: vi.fn(async () => {
throw missingTableError;
}),
},
};
await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]);
await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined();
});
it("returns an explicit error when approval storage is missing for mutation confirmation", async () => {
const missingTableError = createMissingApprovalTableError();
const missingStore = {
assistantApproval: {
findFirst: vi.fn(async () => {
throw missingTableError;
}),
findMany: vi.fn(async () => {
throw missingTableError;
}),
create: vi.fn(async () => {
throw missingTableError;
}),
updateMany: vi.fn(async () => {
throw missingTableError;
}),
},
};
await expect(createPendingAssistantApproval(
missingStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
)).rejects.toThrow("Assistant approval storage is unavailable");
});
it("does not require confirmation for read-only assistant tools", () => {
expect(canExecuteMutationTool([
{ role: "user", content: "Zeig mir meine Notifications" },
@@ -518,12 +803,31 @@ describe("assistant router tool gating", () => {
);
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations");
expect(toolDescriptions.get("create_org_unit")).toContain("manageResources");
expect(toolDescriptions.get("update_org_unit")).toContain("manageResources");
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
expect(toolDescriptions.get("create_estimate_planning_handoff")).toContain("manageAllocations");
expect(toolDescriptions.get("get_estimate_detail")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("list_estimate_versions")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_estimate_version_snapshot")).toContain("viewCosts");
expect(toolDescriptions.get("get_estimate_weekly_phasing")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_estimate_commercial_terms")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("create_vacation")).toContain("authenticated user");
expect(toolDescriptions.get("approve_vacation")).toContain("Manager or admin role");
expect(toolDescriptions.get("reject_vacation")).toContain("Manager or admin role");
expect(toolDescriptions.get("cancel_vacation")).toContain("Users can cancel their own requests");
expect(toolDescriptions.get("set_entitlement")).toContain("Manager or admin role");
expect(toolDescriptions.get("create_role")).toContain("manageRoles");
expect(toolDescriptions.get("update_role")).toContain("manageRoles");
expect(toolDescriptions.get("delete_role")).toContain("manageRoles");
expect(toolDescriptions.get("create_org_unit")).toContain("Admin role");
expect(toolDescriptions.get("update_org_unit")).toContain("Admin role");
expect(toolDescriptions.get("list_users")).toContain("Admin role");
expect(toolDescriptions.get("list_assignable_users")).toContain("Manager or admin role");
expect(toolDescriptions.get("get_current_user")).toContain("authenticated user's own profile");
expect(toolDescriptions.get("create_notification")).toContain("Manager or admin role");
expect(toolDescriptions.get("create_task_for_user")).toContain("Manager or admin role");
expect(toolDescriptions.get("send_broadcast")).toContain("Manager or admin role");
expect(toolDescriptions.get("get_broadcast_detail")).toContain("Manager or admin role");
expect(toolDescriptions.get("create_client")).toContain("manager or admin role");
expect(toolDescriptions.get("update_client")).toContain("manager or admin role");
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role");
expect(toolDescriptions.get("query_change_history")).toContain("Controller/manager/admin");
@@ -534,6 +838,17 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("import_csv_data")).toContain("manager/admin");
expect(toolDescriptions.get("list_dispo_import_batches")).toContain("Admin role");
expect(toolDescriptions.get("get_dispo_import_batch")).toContain("Admin role");
expect(toolDescriptions.get("stage_dispo_import_batch")).toContain("Admin role");
expect(toolDescriptions.get("validate_dispo_import_batch")).toContain("Admin role");
expect(toolDescriptions.get("commit_dispo_import_batch")).toContain("Always confirm first");
expect(toolDescriptions.get("get_system_settings")).toContain("Admin role");
expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first");
expect(toolDescriptions.get("get_ai_configured")).toContain("authenticated user");
expect(toolDescriptions.get("list_system_role_configs")).toContain("authenticated user");
expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role");
expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked");
expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first");
expect(toolDescriptions.get("test_webhook")).toContain("Always confirm first");
expect(toolDescriptions.get("list_audit_log_entries")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_audit_log_entry")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_audit_log_timeline")).toContain("Controller/manager/admin");
@@ -548,4 +863,72 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations");
expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin");
});
it("aligns assistant tool visibility with router role and permission rules", () => {
const managerWithRolePermission = getToolNames(
[PermissionKey.MANAGE_ROLES],
SystemRole.MANAGER,
);
const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER);
expect(managerWithRolePermission).toContain("create_role");
expect(managerWithRolePermission).toContain("update_role");
expect(managerWithRolePermission).toContain("delete_role");
expect(managerWithRolePermission).toContain("create_client");
expect(managerWithRolePermission).toContain("update_client");
expect(managerWithRolePermission).not.toContain("create_org_unit");
expect(managerWithRolePermission).not.toContain("update_org_unit");
expect(managerWithoutRolePermission).not.toContain("create_role");
expect(managerWithoutRolePermission).not.toContain("update_role");
expect(managerWithoutRolePermission).not.toContain("delete_role");
expect(managerWithoutRolePermission).toContain("create_client");
expect(managerWithoutRolePermission).toContain("update_client");
const adminWithRolePermission = getToolNames(
[PermissionKey.MANAGE_ROLES],
SystemRole.ADMIN,
);
expect(adminWithRolePermission).toContain("create_org_unit");
expect(adminWithRolePermission).toContain("update_org_unit");
const standardUserTools = getToolNames([], SystemRole.USER);
expect(standardUserTools).toContain("get_vacation_balance");
expect(standardUserTools).toContain("create_vacation");
expect(standardUserTools).toContain("cancel_vacation");
expect(standardUserTools).not.toContain("approve_vacation");
expect(standardUserTools).not.toContain("reject_vacation");
expect(standardUserTools).not.toContain("set_entitlement");
const managerVacationTools = getToolNames([], SystemRole.MANAGER);
expect(managerVacationTools).toContain("approve_vacation");
expect(managerVacationTools).toContain("reject_vacation");
expect(managerVacationTools).toContain("set_entitlement");
});
it("keeps estimate tool parameter enums aligned with the current estimate schema", () => {
const definitionByName = new Map(
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function]),
);
const createEstimateStatus = (
definitionByName.get("create_estimate")?.parameters as {
properties?: Record<string, { enum?: unknown[] }>;
}
)?.properties?.status?.enum;
const updateEstimateStatus = (
definitionByName.get("update_estimate_draft")?.parameters as {
properties?: Record<string, { enum?: unknown[] }>;
}
)?.properties?.status?.enum;
const estimateExportFormats = (
definitionByName.get("create_estimate_export")?.parameters as {
properties?: Record<string, { enum?: unknown[] }>;
}
)?.properties?.format?.enum;
expect(createEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]);
expect(updateEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]);
expect(estimateExportFormats).toEqual(["XLSX", "CSV", "JSON", "SAP", "MMP"]);
});
});
@@ -126,6 +126,15 @@ describe("assistant advanced tools and scoping", () => {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "project_lari",
name: "Gelddruckmaschine",
shortCode: "LARI",
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
})
.mockResolvedValueOnce({
id: "project_lari",
name: "Gelddruckmaschine",
@@ -228,6 +237,101 @@ describe("assistant advanced tools and scoping", () => {
);
});
it("returns project shift preview details from the canonical timeline router", async () => {
const projectFindUnique = vi.fn().mockImplementation((args: { where?: { id?: string; shortCode?: string }; select?: Record<string, unknown> }) => {
if (args.where?.id === "GDM") {
return Promise.resolve(null);
}
if (args.where?.shortCode === "GDM") {
return Promise.resolve({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
}
if (args.select && "budgetCents" in args.select) {
return Promise.resolve({
id: "project_shift",
budgetCents: 100000,
winProbability: 100,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
}
return Promise.resolve({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
});
const ctx = createToolContext(
{
project: {
findUnique: projectFindUnique,
findFirst: vi.fn(),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
},
[PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"preview_project_shift",
JSON.stringify({
projectIdentifier: "GDM",
newStartDate: "2026-01-19",
newEndDate: "2026-01-30",
}),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
project: {
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: "2026-01-05",
endDate: "2026-01-16",
},
requestedShift: {
newStartDate: "2026-01-19",
newEndDate: "2026-01-30",
},
preview: {
valid: true,
errors: [],
warnings: [],
conflictDetails: [],
costImpact: {
currentTotalCents: 0,
newTotalCents: 0,
deltaCents: 0,
budgetCents: 100000,
budgetUtilizationBefore: 0,
budgetUtilizationAfter: 0,
wouldExceedBudget: false,
},
},
});
});
it("returns timeline entries view with demand, assignment, and holiday overlay context", async () => {
const ctx = createToolContext(
{
@@ -1248,9 +1352,94 @@ describe("assistant advanced tools and scoping", () => {
]));
});
it("scopes assistant notification listing to the current user", async () => {
it("returns a filtered project computation graph through the assistant", async () => {
const projectRecord = {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
winProbability: 75,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-02-28T00:00:00.000Z"),
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
};
const ctx = createToolContext(
{
project: {
findUnique: vi.fn().mockResolvedValue(projectRecord),
findFirst: vi.fn(),
findUniqueOrThrow: vi.fn().mockResolvedValue(projectRecord),
},
estimate: {
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
status: "CONFIRMED",
dailyCostCents: 4_000,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-30T00:00:00.000Z"),
hoursPerDay: 4,
},
]),
},
effortRule: {
count: vi.fn().mockResolvedValue(0),
},
experienceMultiplierRule: {
count: vi.fn().mockResolvedValue(0),
},
},
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"get_project_computation_graph",
JSON.stringify({
projectId: "project_1",
domain: "BUDGET",
includeLinks: true,
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
project: { id: string; shortCode: string; name: string };
requestedDomain: string;
totalNodeCount: number;
selectedNodeCount: number;
selectedLinkCount: number;
nodes: Array<{ id: string; domain: string }>;
links: Array<{ source: string; target: string }>;
meta: { projectName: string; projectCode: string };
};
expect(parsed.project).toEqual({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
});
expect(parsed.meta).toEqual({
projectName: "Gelddruckmaschine",
projectCode: "GDM",
});
expect(parsed.requestedDomain).toBe("BUDGET");
expect(parsed.totalNodeCount).toBeGreaterThan(parsed.selectedNodeCount);
expect(parsed.selectedNodeCount).toBeGreaterThan(0);
expect(parsed.selectedLinkCount).toBeGreaterThan(0);
expect(parsed.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
expect(parsed.links.length).toBe(parsed.selectedLinkCount);
});
it("scopes assistant notification listing to the current user through the router path", async () => {
const findMany = vi.fn().mockResolvedValue([]);
const ctx = createToolContext({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
},
notification: {
findMany,
},
@@ -1268,40 +1457,44 @@ describe("assistant advanced tools and scoping", () => {
);
});
it("rejects marking notifications that do not belong to the current user", async () => {
it("scopes mark_notification_read mutations to the current user through the router path", async () => {
const update = vi.fn();
const ctx = createToolContext({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
},
notification: {
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
update,
},
});
const result = await executeTool(
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).toHaveBeenCalledWith({
where: { id: "notif_1", userId: "user_1" },
data: expect.objectContaining({
readAt: expect.any(Date),
}),
});
expect(update).not.toHaveBeenCalled();
});
it("requires manageUsers before listing users through the assistant", async () => {
it("requires admin role before listing users through the assistant", async () => {
const findMany = vi.fn();
const ctx = createToolContext({
user: {
findMany,
},
});
}, [], SystemRole.MANAGER);
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),
error: expect.stringContaining("Admin role required"),
}),
);
expect(findMany).not.toHaveBeenCalled();
@@ -12,30 +12,50 @@ function createToolContext(
userId: "user_1",
userRole,
permissions: new Set(permissions) as ToolContext["permissions"],
session: {
user: { email: "assistant@example.com", name: "Assistant User", image: null },
expires: "2026-03-29T00:00:00.000Z",
},
dbUser: {
id: "user_1",
systemRole: userRole,
permissionOverrides: null,
},
roleDefaults: null,
};
}
describe("assistant country tools", () => {
it("lists countries with schedule rules, active state, and metro cities", async () => {
const findMany = vi.fn().mockResolvedValue([
{
id: "country_de",
code: "DE",
name: "Deutschland",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [{ id: "city_muc", name: "Munich" }],
},
{
id: "country_es",
code: "ES",
name: "Spain",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [{ id: "city_mad", name: "Madrid" }],
},
]);
const ctx = createToolContext({
country: {
findMany: vi.fn().mockResolvedValue([
{
id: "country_de",
code: "DE",
name: "Deutschland",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [{ id: "city_muc", name: "Munich" }],
},
]),
findMany,
},
});
const result = await executeTool(
"list_countries",
JSON.stringify({ includeInactive: true }),
JSON.stringify({ search: "deu" }),
ctx,
);
@@ -49,6 +69,11 @@ describe("assistant country tools", () => {
}>;
};
expect(findMany).toHaveBeenCalledWith({
where: { isActive: true },
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
});
expect(parsed.count).toBe(1);
expect(parsed.countries[0]).toMatchObject({
code: "DE",
@@ -6,6 +6,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
return {
...actual,
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
};
});
@@ -25,6 +26,16 @@ function createToolContext(
userId: "user_1",
userRole,
permissions: new Set(permissions) as ToolContext["permissions"],
session: {
user: { email: "assistant@example.com", name: "Assistant User", image: null },
expires: "2026-03-29T00:00:00.000Z",
},
dbUser: {
id: "user_1",
systemRole: userRole,
permissionOverrides: null,
},
roleDefaults: null,
};
}
@@ -78,6 +89,7 @@ describe("assistant holiday tools", () => {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner" })
.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(),
},
@@ -172,10 +184,10 @@ describe("assistant holiday tools", () => {
it("previews resolved holiday calendars for a scope and shows the source calendar", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
},
metroCity: {
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
findUnique: vi.fn().mockResolvedValue({ id: "city_augsburg", name: "Augsburg", countryId: "country_de" }),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
@@ -229,6 +241,14 @@ describe("assistant holiday tools", () => {
}),
]),
);
expect(ctx.db.country.findUnique).toHaveBeenCalledWith({
where: { id: "country_de" },
select: { id: true, code: true, name: true },
});
expect(ctx.db.metroCity.findUnique).toHaveBeenCalledWith({
where: { id: "city_augsburg" },
select: { id: true, name: true, countryId: true },
});
});
it("creates a holiday calendar through the assistant for admin users", async () => {
@@ -301,36 +321,58 @@ describe("assistant holiday tools", () => {
});
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
const resourceRecord = {
id: "res_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
lcrCents: 5000,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { id: "country_de", code: "DE", name: "Deutschland", dailyWorkingHours: 8, scheduleRules: null },
metroCity: null,
managementLevelGroup: null,
};
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,
}),
.mockResolvedValueOnce(resourceRecord),
findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
dailyCostCents: 40000,
status: "CONFIRMED",
project: { name: "Gamma", shortCode: "GAM" },
project: {
id: "project_gamma",
name: "Gamma",
shortCode: "GAM",
budgetCents: null,
winProbability: 100,
utilizationCategory: { code: "Chg" },
},
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
calculationRule: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
@@ -356,12 +398,12 @@ describe("assistant holiday tools", () => {
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.baseWorkingDays).toBe(22);
expect(parsed.baseAvailableHours).toBe(176);
expect(parsed.availableHours).toBe(160);
expect(parsed.workingDays).toBe(20);
expect(parsed.targetHours).toBe(128);
expect(parsed.unassignedHours).toBe(152);
expect(parsed.locationContext.federalState).toBe("BY");
expect(parsed.holidaySummary).toEqual(
expect.objectContaining({
@@ -409,7 +451,6 @@ describe("assistant holiday tools", () => {
}>;
};
expect(getDashboardBudgetForecast).toHaveBeenCalled();
expect(parsed.forecasts).toEqual([
expect.objectContaining({
projectName: "Gelddruckmaschine",
@@ -425,21 +466,23 @@ describe("assistant holiday tools", () => {
});
it("checks resource availability with regional holidays excluded from capacity", async () => {
const resourceRecord = {
id: "res_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
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: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
};
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,
}),
.mockResolvedValue(resourceRecord),
findFirst: vi.fn(),
},
assignment: {
@@ -581,13 +624,17 @@ describe("assistant holiday tools", () => {
it("prefers resources without a local holiday in staffing suggestions", async () => {
const db = {
project: {
findFirst: vi.fn().mockResolvedValue({
findUnique: 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"),
}),
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
@@ -597,15 +644,17 @@ describe("assistant holiday tools", () => {
eid: "BY-1",
fte: 1,
lcrCents: 10000,
chargeabilityTarget: 80,
valueScore: 10,
skills: [],
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
country: { code: "DE", name: "Deutschland" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
{
id: "res_hh",
@@ -613,21 +662,20 @@ describe("assistant holiday tools", () => {
eid: "HH-1",
fte: 1,
lcrCents: 10000,
chargeabilityTarget: 80,
valueScore: 10,
skills: [],
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: null,
country: { code: "DE" },
country: { code: "DE", name: "Deutschland" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
@@ -645,6 +693,16 @@ describe("assistant holiday tools", () => {
expect(parsed.suggestions[0]).toEqual(
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
);
expect(db.project.findUnique).toHaveBeenCalledWith({
where: { id: "project_1" },
select: expect.objectContaining({
id: true,
shortCode: true,
name: true,
startDate: true,
endDate: true,
}),
});
});
it("finds capacity with local holidays respected", async () => {
@@ -714,6 +772,12 @@ describe("assistant holiday tools", () => {
id: "project_1",
name: "Holiday Project",
shortCode: "HP",
status: "ACTIVE",
responsiblePerson: null,
})
.mockResolvedValueOnce({
id: "project_1",
name: "Holiday Project",
shoringThreshold: 55,
onshoreCountryCode: "DE",
}),
@@ -726,6 +790,7 @@ describe("assistant holiday tools", () => {
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
id: "res_by",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
@@ -740,6 +805,7 @@ describe("assistant holiday tools", () => {
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
id: "res_in",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_in",
federalState: null,
@@ -765,4 +831,121 @@ describe("assistant holiday tools", () => {
expect(result.content).toContain("0% onshore (DE), 100% offshore");
expect(result.content).toContain("IN 100% (1 people)");
});
it("routes pending vacation approvals through the vacation router path", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
type: "ANNUAL",
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-03T00:00:00.000Z"),
isHalfDay: false,
resource: { displayName: "Bruce Banner", eid: "BB-1", chapter: "CGI" },
requestedBy: { id: "user_2", name: "Manager", email: "manager@example.com" },
},
]),
},
};
const ctx = createToolContext(db, [], SystemRole.MANAGER);
const result = await executeTool(
"get_pending_vacation_approvals",
JSON.stringify({ limit: 10 }),
ctx,
);
expect(db.vacation.findMany).toHaveBeenCalledWith({
where: { status: "PENDING" },
include: {
resource: { select: expect.any(Object) },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
});
expect(JSON.parse(result.content)).toEqual([
expect.objectContaining({
id: "vac_1",
resource: "Bruce Banner",
eid: "BB-1",
chapter: "CGI",
}),
]);
});
it("routes team vacation overlap through the vacation router path", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValue({
id: "res_1",
displayName: "Bruce Banner",
eid: "BB-1",
chapter: "CGI",
lcrCents: 0,
isActive: true,
countryId: null,
federalState: null,
metroCityId: null,
areaRole: null,
country: null,
metroCity: null,
}),
findFirst: vi.fn(),
findMany: vi.fn(),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
type: "ANNUAL",
status: "APPROVED",
startDate: new Date("2026-08-10T00:00:00.000Z"),
endDate: new Date("2026-08-12T00:00:00.000Z"),
resource: { displayName: "Clark Kent" },
},
]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_team_vacation_overlap",
JSON.stringify({
resourceId: "res_1",
startDate: "2026-08-10",
endDate: "2026-08-12",
}),
ctx,
);
expect(db.vacation.findMany).toHaveBeenCalledWith({
where: {
resource: { chapter: "CGI" },
resourceId: { not: "res_1" },
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: new Date("2026-08-12T00:00:00.000Z") },
endDate: { gte: new Date("2026-08-10T00:00:00.000Z") },
},
include: {
resource: { select: expect.any(Object) },
},
orderBy: { startDate: "asc" },
take: 20,
});
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
resource: "Bruce Banner",
chapter: "CGI",
overlapCount: 1,
overlappingVacations: [
expect.objectContaining({
resource: "Clark Kent",
status: "APPROVED",
}),
],
}),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { auditLogRouter } from "../router/audit-log.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(auditLogRouter);
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_1",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("audit log router detail endpoints", () => {
it("returns formatted list detail rows with ISO timestamps", async () => {
const db = {
auditLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "audit_1",
entityType: "project",
entityId: "project_1",
entityName: "Apollo",
action: "updated",
userId: "user_1",
source: "ui",
summary: "Changed budget",
createdAt: new Date("2026-03-29T12:00:00.000Z"),
user: {
id: "user_1",
name: "Controller User",
email: "controller@example.com",
},
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.listDetail({ limit: 10 });
expect(result).toEqual({
items: [
{
id: "audit_1",
entityType: "project",
entityId: "project_1",
entityName: "Apollo",
action: "updated",
userId: "user_1",
source: "ui",
summary: "Changed budget",
createdAt: "2026-03-29T12:00:00.000Z",
user: {
id: "user_1",
name: "Controller User",
email: "controller@example.com",
},
},
],
nextCursor: null,
});
});
it("returns formatted timeline detail grouped by date", async () => {
const db = {
auditLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "audit_2",
entityType: "resource",
entityId: "resource_1",
entityName: "Peter Parker",
action: "updated",
userId: "user_2",
source: "assistant",
summary: "Updated location",
changes: { city: ["Hamburg", "Munich"] },
createdAt: new Date("2026-03-30T08:00:00.000Z"),
user: {
id: "user_2",
name: "Audit User",
email: "audit@example.com",
},
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.getTimelineDetail({ limit: 10 });
expect(result).toEqual({
"2026-03-30": [
{
id: "audit_2",
entityType: "resource",
entityId: "resource_1",
entityName: "Peter Parker",
action: "updated",
userId: "user_2",
source: "assistant",
summary: "Updated location",
createdAt: "2026-03-30T08:00:00.000Z",
changes: { city: ["Hamburg", "Munich"] },
user: {
id: "user_2",
name: "Audit User",
email: "audit@example.com",
},
},
],
});
});
});
@@ -425,4 +425,111 @@ describe("chargeability report router", () => {
expect(month).toBeDefined();
expect(month?.chg).toBeCloseTo(16 / 144, 5);
});
it("returns a filtered detailed report with rounded percentages", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
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",
code: "ES",
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: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_confirmed", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_confirmed",
resourceId: "resource_1",
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_confirmed",
name: "Confirmed Project",
shortCode: "CP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const result = await caller.getDetail({
startMonth: "2026-03",
endMonth: "2026-03",
resourceQuery: "ali",
resourceLimit: 10,
});
expect(result.filters).toEqual({
startMonth: "2026-03",
endMonth: "2026-03",
orgUnitId: null,
managementLevelGroupId: null,
countryId: null,
includeProposed: false,
resourceQuery: "ali",
});
expect(result.groupTotals).toEqual([
expect.objectContaining({
monthKey: "2026-03",
totalFte: 1,
chargeabilityPct: expect.any(Number),
targetPct: 80,
}),
]);
expect(result.resourceCount).toBe(1);
expect(result.returnedResourceCount).toBe(1);
expect(result.truncated).toBe(false);
expect(result.resources).toEqual([
expect.objectContaining({
displayName: "Alice",
targetPct: 80,
country: "ES",
city: "Barcelona",
managementLevelGroup: "Senior",
managementLevel: "L7",
months: [
expect.objectContaining({
monthKey: "2026-03",
sah: expect.any(Number),
chargeabilityPct: expect.any(Number),
gapPct: expect.any(Number),
}),
],
}),
]);
});
});
@@ -27,6 +27,32 @@ type ResourceGraphMeta = {
};
};
type ResourceGraphDetail = {
resource: { id: string; eid: string; displayName: string };
availableDomains: string[];
requestedDomain: string | null;
totalNodeCount: number;
totalLinkCount: number;
selectedNodeCount: number;
selectedLinkCount: number;
nodes: Array<{ id: string; domain: string }>;
};
type ProjectGraphDetail = {
project: { id: string; shortCode: string; name: string };
availableDomains: string[];
requestedDomain: string | null;
totalNodeCount: number;
selectedNodeCount: number;
selectedLinkCount: number;
nodes: Array<{ id: string; domain: string }>;
links?: Array<{ source: string; target: string }>;
meta: {
projectName: string;
projectCode: string;
};
};
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
@@ -99,6 +125,47 @@ function buildResource(overrides: Record<string, unknown> = {}) {
};
}
function createProjectDb(projectFindImpl: ReturnType<typeof vi.fn>) {
return {
project: {
findUniqueOrThrow: projectFindImpl,
},
estimate: {
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
status: "CONFIRMED",
dailyCostCents: 4_000,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-30T00:00:00.000Z"),
hoursPerDay: 4,
},
]),
},
effortRule: {
count: vi.fn().mockResolvedValue(0),
},
experienceMultiplierRule: {
count: vi.fn().mockResolvedValue(0),
},
};
}
function buildProject(overrides: Record<string, unknown> = {}) {
return {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
winProbability: 75,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-02-28T00:00:00.000Z"),
...overrides,
};
}
describe("computation graph router", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -192,4 +259,60 @@ describe("computation graph router", () => {
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
]));
});
it("returns a filtered resource detail graph with canonical selection metadata", 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.getResourceDataDetail({
resourceId: "resource_augsburg",
month: "2026-08",
domain: "SAH",
}) as ResourceGraphDetail;
expect(result.resource).toEqual({
id: "resource_augsburg",
eid: "bruce.banner",
displayName: "Bruce Banner",
});
expect(result.availableDomains).toEqual(expect.arrayContaining(["INPUT", "SAH", "ALLOCATION", "CHARGEABILITY"]));
expect(result.requestedDomain).toBe("SAH");
expect(result.totalNodeCount).toBeGreaterThan(result.selectedNodeCount);
expect(result.totalLinkCount).toBeGreaterThan(0);
expect(result.selectedNodeCount).toBeGreaterThan(0);
expect(result.selectedLinkCount).toBe(0);
expect(result.nodes.every((node) => node.domain === "SAH")).toBe(true);
});
it("returns a filtered project detail graph with canonical project identity", async () => {
const db = createProjectDb(vi.fn().mockResolvedValue(buildProject()));
const caller = createControllerCaller(db);
const result = await caller.getProjectDataDetail({
projectId: "project_1",
domain: "BUDGET",
includeLinks: true,
}) as ProjectGraphDetail;
expect(result.project).toEqual({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
});
expect(result.meta).toEqual({
projectName: "Gelddruckmaschine",
projectCode: "GDM",
});
expect(result.availableDomains).toEqual(expect.arrayContaining(["INPUT", "BUDGET"]));
expect(result.requestedDomain).toBe("BUDGET");
expect(result.totalNodeCount).toBeGreaterThan(result.selectedNodeCount);
expect(result.selectedNodeCount).toBeGreaterThan(0);
expect(result.selectedLinkCount).toBeGreaterThan(0);
expect(result.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
expect(result.links?.length).toBe(result.selectedLinkCount);
});
});
@@ -11,6 +11,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
getDashboardTopValueResources: vi.fn(),
getDashboardChargeabilityOverview: vi.fn(),
getDashboardBudgetForecast: vi.fn(),
getDashboardProjectHealth: vi.fn(),
};
});
@@ -31,6 +32,7 @@ import {
getDashboardTopValueResources,
getDashboardChargeabilityOverview,
getDashboardBudgetForecast,
getDashboardProjectHealth,
} from "@capakraken/application";
import { dashboardRouter } from "../router/dashboard.js";
import { createCallerFactory } from "../trpc.js";
@@ -97,7 +99,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardOverview).mockResolvedValue(overview);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getOverview();
expect(result).toMatchObject({
@@ -115,6 +117,72 @@ describe("dashboard router", () => {
});
});
describe("getStatisticsDetail", () => {
it("returns assistant-friendly statistics derived from the canonical dashboard overview", async () => {
vi.mocked(getDashboardOverview).mockResolvedValue({
totalResources: 12,
activeResources: 10,
inactiveResources: 2,
totalProjects: 7,
activeProjects: 4,
inactiveProjects: 3,
totalAllocations: 21,
activeAllocations: 18,
cancelledAllocations: 3,
approvedVacations: 6,
totalEstimates: 9,
budgetSummary: {
totalBudgetCents: 1_234_56,
totalCostCents: 654_32,
avgUtilizationPercent: 53,
},
budgetBasis: {
remainingBudgetCents: 58_024,
budgetedProjects: 5,
unbudgetedProjects: 2,
trackedAssignmentCount: 18,
windowStart: null,
windowEnd: null,
},
projectsByStatus: [
{ status: "ACTIVE", count: 4 },
{ status: "DRAFT", count: 2 },
{ status: "DONE", count: 1 },
],
chapterUtilization: [
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
{ chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 },
{ chapter: "Unassigned", resourceCount: 2, avgChargeabilityTarget: 0 },
],
recentActivity: [],
});
const caller = createControllerCaller({});
const result = await caller.getStatisticsDetail();
expect(getDashboardOverview).toHaveBeenCalledTimes(1);
expect(result).toEqual({
activeResources: 10,
totalProjects: 7,
activeProjects: 4,
totalAllocations: 21,
approvedVacations: 6,
totalEstimates: 9,
totalBudget: "1.234,56 EUR",
projectsByStatus: {
ACTIVE: 4,
DRAFT: 2,
DONE: 1,
},
topChapters: [
{ chapter: "CGI", count: 5 },
{ chapter: "Compositing", count: 3 },
{ chapter: "Unassigned", count: 2 },
],
});
});
});
// ─── getPeakTimes ─────────────────────────────────────────────────────────
describe("getPeakTimes", () => {
@@ -126,7 +194,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardPeakTimes).mockResolvedValue(peakData);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getPeakTimes({
startDate: "2026-03-01T00:00:00.000Z",
endDate: "2026-06-30T00:00:00.000Z",
@@ -148,7 +216,7 @@ describe("dashboard router", () => {
it("passes week granularity to application layer", async () => {
vi.mocked(getDashboardPeakTimes).mockResolvedValue([]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
await caller.getPeakTimes({
startDate: "2026-03-01T00:00:00.000Z",
endDate: "2026-03-31T00:00:00.000Z",
@@ -177,7 +245,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardDemand).mockResolvedValue(demandData);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getDemand({
startDate: "2026-01-01T00:00:00.000Z",
endDate: "2026-12-31T00:00:00.000Z",
@@ -194,7 +262,7 @@ describe("dashboard router", () => {
it("supports grouping by chapter", async () => {
vi.mocked(getDashboardDemand).mockResolvedValue([]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
await caller.getDemand({
startDate: "2026-06-01T00:00:00.000Z",
endDate: "2026-06-30T00:00:00.000Z",
@@ -208,6 +276,73 @@ describe("dashboard router", () => {
});
});
describe("getProjectHealthDetail", () => {
it("returns assistant-friendly health detail derived from the canonical dashboard read model", async () => {
vi.mocked(getDashboardProjectHealth).mockResolvedValue([
{
id: "project_critical",
projectName: "Critical Project",
shortCode: "CRIT",
status: "ACTIVE",
clientId: "client_1",
clientName: "Acme",
budgetHealth: 25,
staffingHealth: 40,
timelineHealth: 30,
compositeScore: 35,
},
{
id: "project_healthy",
projectName: "Healthy Project",
shortCode: "HLTH",
status: "ACTIVE",
clientId: "client_1",
clientName: "Acme",
budgetHealth: 90,
staffingHealth: 92,
timelineHealth: 86,
compositeScore: 89,
},
]);
const caller = createControllerCaller({});
const result = await caller.getProjectHealthDetail();
expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1);
expect(result).toEqual({
projects: [
{
projectId: "project_critical",
projectName: "Critical Project",
shortCode: "CRIT",
status: "ACTIVE",
overall: 35,
budget: 25,
staffing: 40,
timeline: 30,
rating: "critical",
},
{
projectId: "project_healthy",
projectName: "Healthy Project",
shortCode: "HLTH",
status: "ACTIVE",
overall: 89,
budget: 90,
staffing: 92,
timeline: 86,
rating: "healthy",
},
],
summary: {
healthy: 1,
atRisk: 0,
critical: 1,
},
});
});
});
// ─── getTopValueResources ─────────────────────────────────────────────────
describe("getTopValueResources", () => {
@@ -219,7 +354,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getTopValueResources({ limit: 10 });
expect(result).toHaveLength(2);
@@ -232,7 +367,7 @@ describe("dashboard router", () => {
it("respects custom limit", async () => {
vi.mocked(getDashboardTopValueResources).mockResolvedValue([]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
await caller.getTopValueResources({ limit: 5 });
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
@@ -334,7 +469,7 @@ describe("dashboard router", () => {
},
]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getBudgetForecast();
expect(result).toHaveLength(1);
@@ -351,5 +486,177 @@ describe("dashboard router", () => {
});
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
});
it("returns assistant-friendly budget forecast detail derived from the canonical dashboard read model", 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 = createControllerCaller({});
const result = await caller.getBudgetForecastDetail();
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
expect(result).toEqual({
forecasts: [
expect.objectContaining({
projectId: "project_1",
projectName: "Alpha",
shortCode: "ALPHA",
budgetCents: 100_000,
spentCents: 40_000,
remainingCents: 60_000,
projectedCents: 100_000,
burnRateCents: 10_000,
utilization: "40%",
burnStatus: "on_track",
calendarLocations: [
expect.objectContaining({
countryCode: "DE",
federalState: "BY",
metroCityName: "Munich",
}),
],
}),
],
});
});
});
describe("getDetail", () => {
it("returns the canonical assistant dashboard detail payload", async () => {
vi.mocked(getDashboardOverview).mockResolvedValue({
budgetBasis: {
windowStart: "2026-01-01T00:00:00.000Z",
windowEnd: "2026-06-30T00:00:00.000Z",
},
chapterUtilization: [
{
chapter: "Delivery",
resourceCount: 4,
avgChargeabilityTarget: 78,
},
],
});
vi.mocked(getDashboardPeakTimes).mockResolvedValue([
{
period: "2026-03",
totalHours: 320.4,
capacityHours: 400.2,
utilizationPct: 80,
},
]);
vi.mocked(getDashboardTopValueResources).mockResolvedValue([
{
id: "res_1",
eid: "pparker",
displayName: "Peter Parker",
chapter: "Delivery",
valueScore: 91,
lcrCents: 9_500,
},
]);
vi.mocked(getDashboardDemand).mockResolvedValue([
{
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
allocatedHours: 120,
requiredFTEs: 4,
resourceCount: 2,
derivation: {
calendarLocations: [
{
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
resourceCount: 2,
allocatedHours: 120,
},
],
},
},
]);
const caller = createControllerCaller({});
const result = await caller.getDetail({ section: "all" });
expect(result).toEqual({
peakTimes: [
{
month: "2026-03",
totalHours: 320.4,
totalHoursPerDay: 320.4,
capacityHours: 400.2,
utilizationPct: 80,
},
],
topResources: [
{
name: "Peter Parker",
eid: "pparker",
chapter: "Delivery",
lcr: "95,00 EUR",
valueScore: 91,
},
],
demandPipeline: [
{
project: "Gelddruckmaschine (GDM)",
needed: 2,
requiredFTEs: 4,
allocatedResources: 2,
allocatedHours: 120,
calendarLocations: [
{
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
resourceCount: 2,
allocatedHours: 120,
},
],
},
],
chargeabilityByChapter: [
{
chapter: "Delivery",
headcount: 4,
avgTarget: "78%",
},
],
});
expect(getDashboardPeakTimes).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-06-30T00:00:00.000Z"),
granularity: "month",
groupBy: "project",
}),
);
});
});
});
@@ -368,6 +368,54 @@ describe("entitlement.getBalance", () => {
});
});
describe("entitlement.getBalanceDetail", () => {
it("returns assistant-friendly balance detail from the canonical balance workflow", async () => {
const entitlement = sampleEntitlement({ carryoverDays: 0, usedDays: 1, pendingDays: 0.5 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
...(select?.userId ? { userId: "user_1" } : {}),
...(select?.federalState ? { federalState: "BY" } : {}),
...(select?.country ? { country: { code: "DE" } } : {}),
...(select?.metroCity ? { metroCity: null } : {}),
...(select?.displayName ? { displayName: "Alice Example" } : {}),
...(select?.eid ? { eid: "EMP-001" } : {}),
})),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: string } } = {}) => {
if (where?.type === "SICK") {
return [{ startDate: new Date("2026-02-01"), endDate: new Date("2026-02-01"), isHalfDay: false }];
}
return [];
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalanceDetail({ resourceId: "res_1", year: 2026 });
expect(result).toEqual({
resource: "Alice Example",
eid: "EMP-001",
year: 2026,
entitlement: 30,
carryOver: 0,
taken: 1,
pending: 0.5,
remaining: 28.5,
sickDays: 1,
});
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
@@ -624,3 +672,67 @@ describe("entitlement.getYearSummary", () => {
);
});
});
describe("entitlement.getYearSummaryDetail", () => {
it("returns assistant-friendly year summary detail from the canonical summary workflow", async () => {
const resources = [
{ id: "res_1", displayName: "Alice Example", eid: "EMP-001", chapter: "Delivery" },
{ id: "res_2", displayName: "Bob Example", eid: "EMP-002", chapter: "CGI" },
];
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findMany: vi.fn().mockResolvedValue(resources),
findUnique: vi.fn().mockResolvedValue({
federalState: "BY",
country: { code: "DE" },
metroCity: null,
}),
},
vacationEntitlement: {
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { resourceId: string; year: number } } }) => {
if (where.resourceId_year.year !== 2026) {
return null;
}
return sampleEntitlement({
id: `ent_${where.resourceId_year.resourceId}`,
resourceId: where.resourceId_year.resourceId,
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
});
}),
create: vi.fn(),
update: vi.fn().mockImplementation(async (args?: { data?: Record<string, unknown>; where?: { id?: string } }) => ({
...sampleEntitlement({ entitledDays: 28, carryoverDays: 0, usedDays: 0, pendingDays: 0 }),
id: args?.where?.id ?? "ent_updated",
...(args?.data ?? {}),
})),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getYearSummaryDetail({ year: 2026, resourceName: "alice" });
expect(result).toEqual([
{
resource: "Alice Example",
eid: "EMP-001",
chapter: "Delivery",
year: 2026,
entitled: 28,
carryover: 0,
used: 0,
pending: 0,
remaining: 28,
},
]);
});
});
@@ -1168,6 +1168,65 @@ describe("estimate router", () => {
expect.objectContaining({ code: "PRECONDITION_FAILED" }),
);
});
it("throws PRECONDITION_FAILED for demand-line project windows without working days", async () => {
const approvedEstimate = {
...baseEstimate,
projectId: "project_1",
status: EstimateStatus.APPROVED,
versions: [
{
...baseVersion,
id: "ver_approved",
status: EstimateVersionStatus.APPROVED,
lockedAt: new Date("2026-03-13"),
demandLines: [
{
id: "line_1",
name: "Staffing Gap",
hours: 16,
fte: 1,
resourceId: null,
},
],
},
],
};
const findUnique = vi.fn().mockResolvedValue(approvedEstimate);
const projectFindUnique = vi.fn().mockResolvedValue({
id: "project_1",
shortCode: "PRJ1",
name: "Weekend Project",
status: "ACTIVE",
startDate: new Date("2026-03-15"),
endDate: new Date("2026-03-15"),
orderType: "CHARGEABLE",
allocationType: "INT",
winProbability: 100,
budgetCents: 100_000_00,
responsiblePerson: "Test",
});
const db = {
estimate: { findUnique },
project: { findUnique: projectFindUnique },
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
assignment: { findMany: vi.fn().mockResolvedValue([]) },
resource: { findMany: vi.fn().mockResolvedValue([]) },
auditLog: { create: vi.fn() },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
await expect(
caller.createPlanningHandoff({ estimateId: "est_1" }),
).rejects.toThrow(
expect.objectContaining({
code: "PRECONDITION_FAILED",
message: 'Project window has no working days for demand line "Staffing Gap"',
}),
);
});
});
// ─── RBAC ──────────────────────────────────────────────────────────────────
@@ -4,7 +4,9 @@ import {
cancelPendingEvents,
eventBus,
flushPendingEvents,
permissionAudience,
type SseEvent,
userAudience,
} from "../sse/event-bus.js";
// Mock Redis so the module loads without a real connection.
@@ -153,4 +155,80 @@ describe("event-bus debounce", () => {
// The timestamp should be from the first event (not later)
expect(received[0]!.timestamp).toBe(before);
});
it("delivers scoped events only to matching audiences", () => {
const managerReceived: SseEvent[] = [];
const userReceived: SseEvent[] = [];
const unsubscribeManager = eventBus.subscribe((event) => {
managerReceived.push(event);
}, {
audiences: [permissionAudience("manageAllocations")],
includeUnscoped: false,
});
const unsubscribeUser = eventBus.subscribe((event) => {
userReceived.push(event);
}, {
audiences: [userAudience("user_1")],
includeUnscoped: false,
});
eventBus.emit(
SSE_EVENT_TYPES.ALLOCATION_CREATED,
{ id: "a1" },
[permissionAudience("manageAllocations")],
);
eventBus.emit(
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
{ notificationId: "n1" },
[userAudience("user_1")],
);
vi.advanceTimersByTime(50);
expect(managerReceived).toHaveLength(1);
expect(managerReceived[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_CREATED);
expect(userReceived).toHaveLength(1);
expect(userReceived[0]!.type).toBe(SSE_EVENT_TYPES.NOTIFICATION_CREATED);
unsubscribeManager();
unsubscribeUser();
});
it("does not batch events from different audiences together", () => {
const firstUserReceived: SseEvent[] = [];
const secondUserReceived: SseEvent[] = [];
const unsubscribeFirst = eventBus.subscribe((event) => {
firstUserReceived.push(event);
}, {
audiences: [userAudience("user_1")],
includeUnscoped: false,
});
const unsubscribeSecond = eventBus.subscribe((event) => {
secondUserReceived.push(event);
}, {
audiences: [userAudience("user_2")],
includeUnscoped: false,
});
eventBus.emit(
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
{ notificationId: "n1" },
[userAudience("user_1")],
);
eventBus.emit(
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
{ notificationId: "n2" },
[userAudience("user_2")],
);
vi.advanceTimersByTime(50);
expect(firstUserReceived).toHaveLength(1);
expect(firstUserReceived[0]!.payload).toEqual({ notificationId: "n1" });
expect(secondUserReceived).toHaveLength(1);
expect(secondUserReceived[0]!.payload).toEqual({ notificationId: "n2" });
unsubscribeFirst();
unsubscribeSecond();
});
});
@@ -40,6 +40,114 @@ function createAdminCaller(db: Record<string, unknown>) {
}
describe("holiday calendar router", () => {
it("lists holiday calendars with assistant-facing detail formatting", async () => {
const db = {
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_by",
name: "Bayern Feiertage",
scopeType: "STATE",
stateCode: "BY",
isActive: true,
priority: 10,
country: { id: "country_de", code: "DE", name: "Deutschland" },
metroCity: null,
_count: { entries: 2 },
entries: [
{
id: "entry_1",
date: new Date("2026-01-06T00:00:00.000Z"),
name: "Heilige Drei Koenige",
isRecurringAnnual: true,
source: "state",
},
],
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.listCalendarsDetail({
countryCode: "DE",
scopeType: "STATE",
includeInactive: true,
});
expect(result).toEqual({
count: 1,
calendars: [
expect.objectContaining({
id: "cal_by",
name: "Bayern Feiertage",
scopeType: "STATE",
stateCode: "BY",
entryCount: 2,
country: { id: "country_de", code: "DE", name: "Deutschland" },
entries: [
expect.objectContaining({
id: "entry_1",
date: "2026-01-06",
name: "Heilige Drei Koenige",
isRecurringAnnual: true,
source: "state",
}),
],
}),
],
});
});
it("resolves a holiday calendar by identifier with assistant-facing detail formatting", async () => {
const db = {
holidayCalendar: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi
.fn()
.mockResolvedValueOnce({
id: "cal_augsburg",
name: "Augsburg lokal",
scopeType: "CITY",
stateCode: null,
isActive: true,
priority: 5,
country: { id: "country_de", code: "DE", name: "Deutschland" },
metroCity: { id: "city_augsburg", name: "Augsburg" },
entries: [
{
id: "entry_1",
date: new Date("2026-08-08T00:00:00.000Z"),
name: "Friedensfest lokal",
isRecurringAnnual: true,
source: "manual",
},
],
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getCalendarByIdentifierDetail({ identifier: "Augsburg lokal" });
expect(result).toEqual(
expect.objectContaining({
id: "cal_augsburg",
name: "Augsburg lokal",
scopeType: "CITY",
entryCount: 1,
metroCity: { id: "city_augsburg", name: "Augsburg" },
entries: [
expect.objectContaining({
id: "entry_1",
date: "2026-08-08",
name: "Friedensfest lokal",
}),
],
}),
);
});
it("merges built-in and scoped custom holidays in preview", async () => {
const db = {
country: {
@@ -106,6 +214,164 @@ describe("holiday calendar router", () => {
);
});
it("formats preview results for assistant consumption", async () => {
const db = {
country: {
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
},
metroCity: {
findUnique: vi.fn().mockResolvedValue({ id: "city_augsburg", name: "Augsburg", countryId: "country_de" }),
},
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: [
{
id: "entry_1",
date: new Date("2020-08-08T00:00:00.000Z"),
name: "Friedensfest lokal",
isRecurringAnnual: true,
source: "manual",
},
],
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.previewResolvedHolidaysDetail({
countryId: "country_de",
metroCityId: "city_augsburg",
year: 2026,
});
expect(result.locationContext).toEqual({
countryId: "country_de",
countryCode: "DE",
stateCode: null,
metroCityId: "city_augsburg",
metroCity: "Augsburg",
year: 2026,
});
expect(result.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
);
expect(result.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({
date: "2026-08-08",
name: "Friedensfest lokal",
scope: "CITY",
calendarName: "Augsburg lokal",
sourceType: "CUSTOM",
}),
]),
);
});
it("formats resolved holidays by region for assistant consumption", async () => {
const db = {
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.resolveHolidaysDetail({
countryCode: "DE",
stateCode: "BY",
periodStart: new Date("2026-01-01T00:00:00.000Z"),
periodEnd: new Date("2026-12-31T00:00:00.000Z"),
});
expect(result.locationContext).toEqual({
countryId: null,
countryCode: "DE",
federalState: "BY",
metroCityId: null,
metroCity: null,
});
expect(result.count).toBeGreaterThan(0);
expect(result.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
);
expect(result.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
]),
);
});
it("formats resolved holidays for a resource including local city holidays", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
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" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_city",
name: "Augsburg lokal",
scopeType: "CITY",
priority: 5,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
id: "entry_1",
date: new Date("2020-08-08T00:00:00.000Z"),
name: "Augsburger Friedensfest",
isRecurringAnnual: true,
source: "manual",
},
],
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.resolveResourceHolidaysDetail({
resourceId: "res_1",
periodStart: new Date("2026-01-01T00:00:00.000Z"),
periodEnd: new Date("2026-12-31T00:00:00.000Z"),
});
expect(result.resource).toEqual(
expect.objectContaining({
eid: "bruce.banner",
federalState: "BY",
metroCity: "Augsburg",
}),
);
expect(result.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
);
expect(result.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "Augsburger Friedensfest",
date: "2026-08-08",
scope: "CITY",
}),
]),
);
});
it("rejects duplicate calendar scopes on create", async () => {
const db = {
country: {
@@ -0,0 +1,207 @@
import { BlueprintTarget, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { blueprintRouter } from "../router/blueprint.js";
import { clientRouter } from "../router/client.js";
import { countryRouter } from "../router/country.js";
import { orgUnitRouter } from "../router/org-unit.js";
import { roleRouter } from "../router/role.js";
import { createCallerFactory } from "../trpc.js";
function createProtectedContext(db: Record<string, unknown>) {
return {
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
};
}
describe("identifier resolvers", () => {
it("resolves blueprints via a minimal read model", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "bp_1",
name: "Consulting Blueprint",
target: BlueprintTarget.PROJECT,
isActive: true,
});
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
blueprint: {
findUnique,
findFirst: vi.fn(),
},
}));
const result = await caller.resolveByIdentifier({ identifier: "bp_1" });
expect(result).toEqual({
id: "bp_1",
name: "Consulting Blueprint",
target: BlueprintTarget.PROJECT,
isActive: true,
});
expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({
where: { id: "bp_1" },
select: expect.objectContaining({
id: true,
name: true,
target: true,
isActive: true,
}),
}));
});
it("resolves clients by code via a minimal read model", async () => {
const findUnique = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "client_1",
name: "Acme",
code: "ACME",
parentId: null,
isActive: true,
});
const caller = createCallerFactory(clientRouter)(createProtectedContext({
client: {
findUnique,
findFirst: vi.fn(),
},
}));
const result = await caller.resolveByIdentifier({ identifier: "ACME" });
expect(result).toEqual({
id: "client_1",
name: "Acme",
code: "ACME",
parentId: null,
isActive: true,
});
expect(findUnique).toHaveBeenNthCalledWith(2, expect.objectContaining({
where: { code: "ACME" },
select: expect.objectContaining({
id: true,
name: true,
code: true,
parentId: true,
isActive: true,
}),
}));
});
it("resolves countries by code via a minimal read model", async () => {
const findUnique = vi.fn().mockResolvedValue(null);
const findFirst = vi.fn().mockResolvedValue({
id: "country_de",
code: "DE",
name: "Germany",
isActive: true,
dailyWorkingHours: 8,
});
const caller = createCallerFactory(countryRouter)(createProtectedContext({
country: {
findUnique,
findFirst,
},
}));
const result = await caller.resolveByIdentifier({ identifier: "de" });
expect(result).toEqual({
id: "country_de",
code: "DE",
name: "Germany",
isActive: true,
dailyWorkingHours: 8,
});
expect(findFirst).toHaveBeenNthCalledWith(1, expect.objectContaining({
where: { code: { equals: "DE", mode: "insensitive" } },
select: expect.objectContaining({
id: true,
code: true,
name: true,
isActive: true,
dailyWorkingHours: true,
}),
}));
});
it("resolves org units by short name via a minimal read model", async () => {
const findUnique = vi.fn().mockResolvedValue(null);
const findFirst = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "ou_1",
name: "Delivery",
shortName: "DEL",
level: 5,
isActive: true,
});
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
orgUnit: {
findUnique,
findFirst,
},
}));
const result = await caller.resolveByIdentifier({ identifier: "DEL" });
expect(result).toEqual({
id: "ou_1",
name: "Delivery",
shortName: "DEL",
level: 5,
isActive: true,
});
expect(findFirst).toHaveBeenNthCalledWith(2, expect.objectContaining({
where: { shortName: { equals: "DEL", mode: "insensitive" } },
select: expect.objectContaining({
id: true,
name: true,
shortName: true,
level: true,
isActive: true,
}),
}));
});
it("resolves roles without loading planning counts", async () => {
const findUnique = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "role_1",
name: "Designer",
color: "#123456",
isActive: true,
});
const caller = createCallerFactory(roleRouter)(createProtectedContext({
role: {
findUnique,
findFirst: vi.fn(),
},
}));
const result = await caller.resolveByIdentifier({ identifier: "Designer" });
expect(result).toEqual({
id: "role_1",
name: "Designer",
color: "#123456",
isActive: true,
});
expect(findUnique).toHaveBeenNthCalledWith(2, expect.objectContaining({
where: { name: "Designer" },
select: expect.objectContaining({
id: true,
name: true,
color: true,
isActive: true,
}),
}));
});
});
@@ -0,0 +1,198 @@
import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { insightsRouter } from "../router/insights.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(insightsRouter);
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,
},
roleDefaults: null,
});
}
describe("insights router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("derives the summary from the same canonical anomaly snapshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-29T12:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
name: "Apollo",
budgetCents: 100_000,
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
demandRequirements: [
{
headcount: 3,
startDate: new Date("2026-03-20T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
_count: { assignments: 1 },
},
],
assignments: [
{
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 12,
dailyCostCents: 10_000,
status: "ACTIVE",
},
],
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Peter Parker",
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resourceId: "res_1",
hoursPerDay: 12,
},
]),
},
};
const caller = createControllerCaller(db);
const anomalies = await caller.detectAnomalies();
const summary = await caller.getInsightsSummary();
expect(anomalies).toEqual([
expect.objectContaining({ type: "budget", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "staffing", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "utilization", severity: "critical", entityName: "Peter Parker" }),
expect.objectContaining({ type: "timeline", severity: "warning", entityName: "Apollo" }),
]);
expect(summary).toEqual({
total: anomalies.length,
criticalCount: anomalies.filter((anomaly) => anomaly.severity === "critical").length,
budget: anomalies.filter((anomaly) => anomaly.type === "budget").length,
staffing: anomalies.filter((anomaly) => anomaly.type === "staffing").length,
timeline: anomalies.filter((anomaly) => anomaly.type === "timeline").length,
utilization: anomalies.filter((anomaly) => anomaly.type === "utilization").length,
});
expect(db.project.findMany).toHaveBeenCalledTimes(2);
expect(db.resource.findMany).toHaveBeenCalledWith({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
});
} finally {
vi.useRealTimers();
}
});
it("returns assistant-friendly anomaly detail from the canonical snapshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-15T00:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
name: "Apollo",
budgetCents: 100_000,
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
demandRequirements: [
{
headcount: 3,
startDate: new Date("2026-03-10T00:00:00.000Z"),
endDate: new Date("2026-03-20T00:00:00.000Z"),
_count: { assignments: 1 },
},
],
assignments: [
{
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 12,
dailyCostCents: 10_000,
status: "ACTIVE",
},
],
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Peter Parker",
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resourceId: "res_1",
hoursPerDay: 12,
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.getAnomalyDetail();
expect(result).toEqual({
count: 4,
anomalies: [
expect.objectContaining({ type: "budget", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "staffing", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "utilization", severity: "critical", entityName: "Peter Parker" }),
expect.objectContaining({ type: "timeline", severity: "warning", entityName: "Apollo" }),
],
});
expect(db.project.findMany).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -1,6 +1,9 @@
import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { projectRouter } from "../router/project.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("@capakraken/application", async (importOriginal) => {
@@ -26,6 +29,19 @@ vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../ai-client.js", () => ({
isDalleConfigured: vi.fn().mockReturnValue(false),
createDalleClient: vi.fn(),
@@ -155,6 +171,47 @@ describe("project router", () => {
expect(db.auditLog.create).toHaveBeenCalled();
});
it("logs and swallows background cache and webhook failures during create", async () => {
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
const created = { ...sampleProject, id: "project_safe_create" };
const db = {
project: {
findUnique: vi.fn().mockResolvedValue(null),
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: "SAFE-001",
name: "Safe Project",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
winProbability: 80,
budgetCents: 500_000_00,
startDate: new Date("2026-01-01"),
endDate: new Date("2026-06-30"),
});
await Promise.resolve();
await Promise.resolve();
expect(result.id).toBe("project_safe_create");
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
"Project background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.created" }),
"Project background side effect failed",
);
});
it("throws CONFLICT when shortCode already exists", async () => {
const db = {
project: {
@@ -208,7 +265,7 @@ describe("project router", () => {
// ─── getById ──────────────────────────────────────────────────────────────
describe("getById", () => {
it("returns the correct project with allocations and demands", async () => {
it("returns the correct project with allocations and demands for controller-level access", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ ...sampleProject, blueprint: null }),
@@ -218,7 +275,7 @@ describe("project router", () => {
assignment: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createProtectedCaller(db);
const caller = createControllerCaller(db);
const result = await caller.getById({ id: "project_1" });
expect(result.id).toBe("project_1");
@@ -236,11 +293,22 @@ describe("project router", () => {
assignment: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createProtectedCaller(db);
const caller = createControllerCaller(db);
await expect(caller.getById({ id: "missing" })).rejects.toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
});
it("blocks USER role from loading full project planning context", async () => {
const db = {
project: { findUnique: vi.fn() },
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "project_1" })).rejects.toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
});
describe("getShoringRatio", () => {
@@ -292,7 +360,7 @@ describe("project router", () => {
},
};
const caller = createProtectedCaller(db);
const caller = createControllerCaller(db);
const result = await caller.getShoringRatio({ projectId: "project_1" });
expect(result.totalHours).toBe(24);
@@ -373,6 +441,38 @@ describe("project router", () => {
}),
);
});
it("logs and swallows background failures during status changes", async () => {
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
const db = {
project: {
update: vi.fn().mockResolvedValue(updated),
},
webhook: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createManagerCaller(db);
const result = await caller.updateStatus({
id: "project_1",
status: ProjectStatus.COMPLETED,
});
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(ProjectStatus.COMPLETED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
"Project background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.status_changed" }),
"Project background side effect failed",
);
});
});
// ─── batchUpdateStatus ────────────────────────────────────────────────────
@@ -547,4 +647,212 @@ describe("project router", () => {
);
});
});
describe("assistant-facing detail routes", () => {
it("returns lightweight project search summaries from the canonical router", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
client: { name: "Acme Mobility" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 });
expect(result).toEqual([
{
id: "project_1",
code: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
start: "2026-01-01",
end: "2026-03-31",
client: "Acme Mobility",
},
]);
});
it("returns formatted project search summaries from the canonical router", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
budgetCents: 500000,
winProbability: 100,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
client: { name: "Acme Mobility" },
_count: { assignments: 3, estimates: 1 },
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 });
expect(result).toEqual([
{
id: "project_1",
code: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
budget: "5.000,00 EUR",
winProbability: "100%",
start: "2026-01-01",
end: "2026-03-31",
client: "Acme Mobility",
assignmentCount: 3,
estimateCount: 1,
},
]);
});
it("blocks USER role from detailed project search summaries", async () => {
const db = {
project: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 }),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
it("returns lightweight project identifier reads from the canonical router", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
}),
findFirst: vi.fn(),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getByIdentifier({ identifier: "GDM" });
expect(result).toEqual({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
});
});
it("returns formatted project details from the canonical router", async () => {
const db = {
project: {
findUnique: vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
})
.mockResolvedValueOnce({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
budgetCents: 500000,
winProbability: 100,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
responsiblePerson: "Bruce Banner",
client: { name: "Acme Mobility" },
utilizationCategory: { code: "BILLABLE", name: "Billable" },
_count: { assignments: 3, estimates: 1 },
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resource: { displayName: "Bruce Banner", eid: "EMP-001" },
role: "Lead",
status: "ACTIVE",
hoursPerDay: 8,
startDate: new Date("2026-02-01T00:00:00.000Z"),
endDate: new Date("2026-02-28T00:00:00.000Z"),
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.getByIdentifierDetail({ identifier: "GDM" });
expect(result).toEqual({
id: "project_1",
code: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
orderType: "CHARGEABLE",
allocationType: "INT",
budget: "5.000,00 EUR",
budgetCents: 500000,
winProbability: "100%",
start: "2026-01-01",
end: "2026-03-31",
responsible: "Bruce Banner",
client: "Acme Mobility",
category: "Billable",
assignmentCount: 3,
estimateCount: 1,
topAllocations: [
{
resource: "Bruce Banner",
eid: "EMP-001",
role: "Lead",
status: "ACTIVE",
hoursPerDay: 8,
start: "2026-02-01",
end: "2026-02-28",
},
],
});
});
it("blocks USER role from detailed project identifier reads", async () => {
const db = {
project: {
findUnique: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getByIdentifierDetail({ identifier: "GDM" }),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
});
});
@@ -1,15 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import type { inferProcedureInput } from "@trpc/server";
import type { AppRouter } from "../router/index.js";
import { SystemRole } from "@capakraken/shared";
import { rateCardRouter } from "../router/rate-card.js";
import { createCallerFactory } from "../trpc.js";
// Minimal mock helpers
function mockCtx(overrides: Record<string, unknown> = {}) {
return {
ctx: {
session: { user: { id: "user_1", systemRole: "MANAGER" } },
db: overrides,
const createCaller = createCallerFactory(rateCardRouter);
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_1",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("rateCard router", () => {
@@ -60,6 +68,52 @@ describe("rateCard router", () => {
});
describe("resolveRate", () => {
it("resolves a resource-based rate through the canonical router query", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
displayName: "Bruce Banner",
chapter: "Delivery",
areaRole: { name: "Pipeline TD" },
}),
},
role: {
findFirst: vi.fn().mockResolvedValue({ id: "role_1" }),
},
rateCard: {
findMany: vi.fn().mockResolvedValue([
{
id: "rc_2026",
name: "Standard 2026",
client: null,
lines: [
{
id: "line_1",
chapter: "Delivery",
seniority: "Senior",
costRateCents: 12_000,
billRateCents: 18_000,
role: { id: "role_1", name: "Pipeline TD" },
},
],
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.resolveBestRate({ resourceId: "res_1" });
expect(result).toEqual({
rateCard: "Standard 2026",
resource: "Bruce Banner",
rate: "120,00 EUR",
rateCents: 12000,
matchedBy: "role: Pipeline TD",
});
});
it("returns the most specific matching line", () => {
const lines = [
{ id: "rcl_1", roleId: null, chapter: "Digital Content Production", costRateCents: 7000, billRateCents: 12000 },
@@ -115,4 +115,80 @@ describe("report router", () => {
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");
});
it("rejects invalid resource_month period months instead of silently normalizing them", async () => {
const caller = createControllerCaller({});
await expect(caller.getReportData({
entity: "resource_month",
columns: ["displayName"],
filters: [],
periodMonth: "2026-13",
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("Invalid"),
});
});
it("rejects unknown columns instead of silently dropping them", async () => {
const caller = createControllerCaller({
resource: {
findMany: vi.fn(),
count: vi.fn(),
},
});
await expect(caller.getReportData({
entity: "resource",
columns: ["displayName", "unknownColumn"],
filters: [],
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("unknownColumn"),
});
});
it("rejects unsupported relation filters instead of silently ignoring them", async () => {
const caller = createControllerCaller({
assignment: {
findMany: vi.fn(),
count: vi.fn(),
},
});
await expect(caller.getReportData({
entity: "assignment",
columns: ["id", "resource.displayName"],
filters: [{ field: "resource.displayName", op: "contains", value: "Alice" }],
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("resource.displayName"),
});
});
it("rejects invalid numeric filter values instead of silently dropping them", async () => {
const caller = createControllerCaller({
resource: {
findMany: vi.fn(),
count: vi.fn(),
},
});
await expect(caller.getReportData({
entity: "resource",
columns: ["displayName"],
filters: [{ field: "lcrCents", op: "gte", value: "not-a-number" }],
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("lcrCents"),
});
});
});
@@ -112,10 +112,10 @@ describe("resource router CRUD", () => {
vi.clearAllMocks();
});
// ─── list ─────────────────────────────────────────────────────────────────
// ─── listStaff ────────────────────────────────────────────────────────────
describe("list", () => {
it("returns paginated results with total count", async () => {
describe("listStaff", () => {
it("returns paginated results with total count for staff callers", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([sampleResource]),
@@ -123,15 +123,15 @@ describe("resource router CRUD", () => {
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({ limit: 50 });
const caller = createManagerCaller(db);
const result = await caller.listStaff({ limit: 50 });
expect(result.resources).toHaveLength(1);
expect(result.resources[0]?.displayName).toBe("Alice");
expect(db.resource.findMany).toHaveBeenCalled();
});
it("applies search filter", async () => {
it("applies search filter for staff callers", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
@@ -139,8 +139,8 @@ describe("resource router CRUD", () => {
},
};
const caller = createProtectedCaller(db);
await caller.list({ search: "Alice", limit: 50 });
const caller = createManagerCaller(db);
await caller.listStaff({ search: "Alice", limit: 50 });
expect(db.resource.findMany).toHaveBeenCalled();
});
@@ -152,7 +152,8 @@ describe("resource router CRUD", () => {
it("returns correct resource", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(sampleResource),
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ ...sampleResource, userId: "user_1" }),
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
@@ -170,6 +171,7 @@ describe("resource router CRUD", () => {
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
@@ -188,6 +190,7 @@ describe("resource router CRUD", () => {
const ownedResource = { ...sampleResource, userId: "user_1" };
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue(ownedResource),
findMany: vi.fn().mockResolvedValue([]),
},
@@ -201,6 +204,21 @@ describe("resource router CRUD", () => {
expect(result.isOwnedByCurrentUser).toBe(true);
});
it("rejects foreign resources for regular users", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
findUnique: vi.fn().mockResolvedValue(sampleResource),
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "res_1" })).rejects.toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
});
// ─── create ───────────────────────────────────────────────────────────────
@@ -349,6 +367,7 @@ describe("resource router CRUD", () => {
it("returns expected shape with key fields", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
displayName: "Alice",
@@ -387,7 +406,10 @@ describe("resource router CRUD", () => {
it("throws NOT_FOUND for missing resource", async () => {
const db = {
resource: { findUnique: vi.fn().mockResolvedValue(null) },
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue(null),
},
systemSettings: { findUnique: vi.fn().mockResolvedValue(null) },
};
@@ -396,6 +418,37 @@ describe("resource router CRUD", () => {
expect.objectContaining({ code: "NOT_FOUND" }),
);
});
it("rejects foreign hover-card access for regular users", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
displayName: "Alice",
eid: "E-001",
email: "alice@example.com",
chapter: "CGI",
lcrCents: 5000,
ucrCents: 9000,
currency: "EUR",
chargeabilityTarget: 80,
skills: [],
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
isActive: true,
areaRole: null,
country: null,
managementLevel: null,
resourceType: null,
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getHoverCard({ id: "res_1" })).rejects.toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
});
// ─── importSkillMatrix ────────────────────────────────────────────────────
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import { listAssignmentBookings } from "@capakraken/application";
import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { staffingRouter } from "../router/staffing.js";
@@ -245,6 +246,303 @@ describe("staffing.getSuggestions", () => {
});
});
describe("staffing.getProjectStaffingSuggestions", () => {
it("returns canonical project-scoped staffing suggestions with defaults and role filter", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
sampleResource({
id: "res_by",
displayName: "Bavaria",
eid: "BY-1",
areaRole: { name: "Consultant" },
country: { code: "DE", name: "Germany" },
}),
sampleResource({
id: "res_hh",
displayName: "Hamburg",
eid: "HH-1",
federalState: "HH",
areaRole: { name: "Artist" },
country: { code: "DE", name: "Germany" },
}),
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getProjectStaffingSuggestions({
projectId: "project_1",
roleName: "artist",
limit: 5,
});
expect(result).toEqual({
project: "Gelddruckmaschine (GDM)",
period: "2026-01-06 to 2026-01-06",
suggestions: [
{
id: "res_hh",
name: "Hamburg",
eid: "HH-1",
role: "Artist",
chapter: "VFX",
fte: expect.any(Number),
lcr: "75,00 EUR",
workingDays: expect.any(Number),
availableHours: expect.any(Number),
bookedHours: 0,
availableHoursPerDay: expect.any(Number),
utilization: 0,
},
],
});
expect(db.project.findUnique).toHaveBeenCalledWith({
where: { id: "project_1" },
select: {
id: true,
shortCode: true,
name: true,
startDate: true,
endDate: true,
},
});
});
});
describe("staffing.getBestProjectResourceDetail", () => {
it("returns canonical project resource ranking with holiday-aware capacity details", 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 db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_lari",
name: "Gelddruckmaschine",
shortCode: "LARI",
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
}),
},
assignment: {
findMany: assignmentFindMany,
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBestProjectResourceDetail({
projectId: "project_lari",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
minHoursPerDay: 3,
rankingMode: "lowest_lcr",
});
expect(result.project).toEqual({
id: "project_lari",
name: "Gelddruckmaschine",
shortCode: "LARI",
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
});
expect(result.period).toEqual({
startDate: "2026-01-05",
endDate: "2026-01-16",
minHoursPerDay: 3,
rankingMode: "lowest_lcr",
});
expect(result.filters).toEqual({
chapter: null,
roleName: null,
});
expect(result.candidateCount).toBe(2);
expect(result.bestMatch).toEqual(
expect.objectContaining({
name: "Carol Danvers",
remainingHoursPerDay: 6,
lcrCents: 7664,
federalState: "HH",
metroCity: "Hamburg",
baseAvailableHours: 80,
holidaySummary: expect.objectContaining({ count: 0 }),
}),
);
expect(result.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 }),
}),
]);
});
});
describe("staffing.searchCapacity", () => {
it("returns holiday-aware capacity across multiple resources", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
sampleResource({
id: "res_by",
displayName: "Bavaria",
eid: "BY-1",
chapter: "CGI",
areaRole: { name: "Consultant" },
federalState: "BY",
}),
sampleResource({
id: "res_hh",
displayName: "Hamburg",
eid: "HH-1",
chapter: "CGI",
areaRole: { name: "Consultant" },
federalState: "HH",
}),
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.searchCapacity({
startDate: new Date("2026-01-06"),
endDate: new Date("2026-01-06"),
minHoursPerDay: 1,
});
expect(result.results).toHaveLength(1);
expect(result.results[0]).toEqual(
expect.objectContaining({
name: "Hamburg",
availableHours: 8,
availableHoursPerDay: 8,
}),
);
});
it("applies role and chapter filters in the resource query", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.searchCapacity({
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-02"),
minHoursPerDay: 4,
roleName: "Consult",
chapter: "CG",
limit: 5,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
areaRole: { name: { contains: "Consult", mode: "insensitive" } },
chapter: { contains: "CG", mode: "insensitive" },
}),
take: 100,
}),
);
});
});
// ─── analyzeUtilization ──────────────────────────────────────────────────────
describe("staffing.analyzeUtilization", () => {
@@ -0,0 +1,638 @@
import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
listAssignmentBookings: vi.fn(),
};
});
vi.mock("../lib/anonymization.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/anonymization.js")>();
return {
...actual,
getAnonymizationDirectory: vi.fn().mockResolvedValue(null),
};
});
import { listAssignmentBookings } from "@capakraken/application";
import { timelineRouter } from "../router/timeline.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(timelineRouter);
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2026-03-29T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
roleDefaults: null,
});
}
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-03-29T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
roleDefaults: null,
});
}
describe("timeline router detail views", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns a self-service timeline view scoped to the caller's linked resource", async () => {
const demandFindMany = vi.fn();
const assignmentFindMany = vi.fn().mockResolvedValue([
{
id: "asg_self",
projectId: "project_1",
resourceId: "res_self",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_self",
displayName: "Alice",
eid: "EMP-SELF",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
]);
const caller = createProtectedCaller({
demandRequirement: {
findMany: demandFindMany,
},
assignment: {
findMany: assignmentFindMany,
},
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_self" }),
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getMyEntriesView({
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
resourceIds: ["res_other"],
chapters: ["Finance"],
eids: ["EMP-OTHER"],
countryCodes: ["US"],
});
expect(result.assignments).toHaveLength(1);
expect(result.assignments[0]?.resourceId).toBe("res_self");
expect(demandFindMany).not.toHaveBeenCalled();
expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
resourceId: { in: ["res_self"] },
}),
}));
});
it("returns self-service holiday overlays for the caller's linked resource", async () => {
const demandFindMany = vi.fn();
const assignmentFindMany = vi.fn().mockResolvedValue([]);
const resourceFindMany = vi.fn().mockResolvedValue([
{
id: "res_self",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
]);
const caller = createProtectedCaller({
demandRequirement: {
findMany: demandFindMany,
},
assignment: {
findMany: assignmentFindMany,
},
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_self" }),
findMany: resourceFindMany,
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getMyHolidayOverlays({
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
resourceIds: ["res_other"],
chapters: ["Finance"],
eids: ["EMP-OTHER"],
countryCodes: ["US"],
});
expect(result).toEqual([
expect.objectContaining({
resourceId: "res_self",
note: "Heilige Drei Könige",
scope: "STATE",
}),
]);
expect(demandFindMany).not.toHaveBeenCalled();
expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
resourceId: { in: ["res_self"] },
}),
}));
expect(resourceFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: { in: ["res_self"] } },
}));
});
it("returns empty self-service timeline data when the caller has no linked resource", async () => {
const demandFindMany = vi.fn();
const assignmentFindMany = vi.fn();
const caller = createProtectedCaller({
demandRequirement: {
findMany: demandFindMany,
},
assignment: {
findMany: assignmentFindMany,
},
resource: {
findFirst: vi.fn().mockResolvedValue(null),
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getMyEntriesView({
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
});
expect(result.allocations).toEqual([]);
expect(result.demands).toEqual([]);
expect(result.assignments).toEqual([]);
expect(demandFindMany).not.toHaveBeenCalled();
expect(assignmentFindMany).not.toHaveBeenCalled();
});
it("returns a detailed timeline entries view with holiday overlays and summary", async () => {
const caller = createAdminCaller({
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_1",
projectId: "project_1",
resourceId: null,
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "OPEN",
metadata: null,
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asg_by",
projectId: "project_1",
resourceId: "res_by",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_by",
displayName: "Alice",
eid: "EMP-BY",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
{
id: "asg_hh",
projectId: "project_1",
resourceId: "res_hh",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_hh",
displayName: "Bob",
eid: "EMP-HH",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
{
id: "res_hh",
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE" },
metroCity: { name: "Hamburg" },
},
]),
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getEntriesDetail({
startDate: "2026-01-05",
endDate: "2026-01-09",
projectIds: ["project_1"],
});
expect(result.period).toEqual({
startDate: "2026-01-05",
endDate: "2026-01-09",
});
expect(result.summary).toEqual(
expect.objectContaining({
demandCount: 1,
assignmentCount: 2,
overlayCount: 1,
resourceCount: 2,
}),
);
expect(result.demands).toHaveLength(1);
expect(result.assignments).toHaveLength(2);
expect(result.holidayOverlays).toEqual([
expect.objectContaining({
resourceId: "res_by",
startDate: "2026-01-06",
note: "Heilige Drei Könige",
scope: "STATE",
}),
]);
});
it("returns detailed project timeline context with overlap summaries", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "asg_project",
projectId: "project_ctx",
resourceId: "res_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
hoursPerDay: 6,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
},
{
id: "asg_other",
projectId: "project_other",
resourceId: "res_1",
startDate: new Date("2026-01-08T00:00:00.000Z"),
endDate: new Date("2026-01-10T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_other", name: "Other Project", shortCode: "OTH", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
},
]);
const project = {
id: "project_ctx",
name: "Gelddruckmaschine",
shortCode: "GDM",
orderType: "CHARGEABLE",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
};
const caller = createAdminCaller({
project: {
findUnique: vi.fn().mockResolvedValue(project),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_ctx",
projectId: "project_ctx",
resourceId: null,
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "OPEN",
metadata: null,
project,
roleEntity: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asg_project",
projectId: "project_ctx",
resourceId: "res_1",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_1",
displayName: "Alice",
eid: "EMP-1",
chapter: "Delivery",
lcrCents: 10000,
},
project,
roleEntity: null,
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
]),
},
});
const result = await caller.getProjectContextDetail({
projectId: "project_ctx",
});
expect(result.project).toEqual(
expect.objectContaining({
id: "project_ctx",
shortCode: "GDM",
}),
);
expect(result.summary).toEqual(
expect.objectContaining({
demandCount: 1,
assignmentCount: 1,
conflictedAssignmentCount: 1,
overlayCount: 1,
}),
);
expect(result.assignmentConflicts).toEqual([
expect.objectContaining({
assignmentId: "asg_project",
crossProjectOverlapCount: 1,
overlaps: expect.arrayContaining([
expect.objectContaining({
projectShortCode: "OTH",
sameProject: false,
}),
]),
}),
]);
expect(result.holidayOverlays).toEqual([
expect.objectContaining({
startDate: "2026-01-06",
}),
]);
});
it("returns detailed project shift preview metadata and validation", async () => {
const projectFindUnique = vi.fn().mockImplementation((args: { select?: Record<string, unknown> }) => {
if (args.select && "budgetCents" in args.select) {
return Promise.resolve({
id: "project_shift",
budgetCents: 100000,
winProbability: 100,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
}
return Promise.resolve({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
});
const caller = createAdminCaller({
project: {
findUnique: projectFindUnique,
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const result = await caller.getShiftPreviewDetail({
projectId: "project_shift",
newStartDate: new Date("2026-01-19T00:00:00.000Z"),
newEndDate: new Date("2026-01-30T00:00:00.000Z"),
});
expect(result.project).toEqual({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: "2026-01-05",
endDate: "2026-01-16",
});
expect(result.requestedShift).toEqual({
newStartDate: "2026-01-19",
newEndDate: "2026-01-30",
});
expect(result.preview).toEqual({
valid: true,
errors: [],
warnings: [],
conflictDetails: [],
costImpact: {
currentTotalCents: 0,
newTotalCents: 0,
deltaCents: 0,
budgetCents: 100000,
budgetUtilizationBefore: 0,
budgetUtilizationAfter: 0,
wouldExceedBudget: false,
},
});
});
it("blocks USER role from broad timeline detail reads", async () => {
const db = {
demandRequirement: {
findMany: vi.fn(),
},
assignment: {
findMany: vi.fn(),
},
resource: {
findMany: vi.fn(),
},
project: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getEntriesDetail({
startDate: "2026-01-05",
endDate: "2026-01-09",
}),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
it("blocks USER role from project timeline context reads", async () => {
const db = {
project: {
findUnique: vi.fn(),
},
demandRequirement: {
findMany: vi.fn(),
},
assignment: {
findMany: vi.fn(),
},
resource: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getProjectContextDetail({ projectId: "project_ctx" }),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
});
@@ -1,6 +1,9 @@
import { SystemRole } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@capakraken/db";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createNotification } from "../lib/create-notification.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { vacationRouter } from "../router/vacation.js";
import { createCallerFactory } from "../trpc.js";
@@ -33,6 +36,15 @@ vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
@@ -163,6 +175,9 @@ describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
@@ -183,6 +198,9 @@ describe("vacation router", () => {
it("applies resourceId filter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -198,8 +216,48 @@ describe("vacation router", () => {
);
});
it("scopes regular users to their own resource when no filter is provided", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({});
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ resourceId: "res_own" }),
}),
);
});
it("forbids regular users from listing another resource's vacations", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(caller.list({ resourceId: "res_other" })).rejects.toThrow(
"You can only view vacation data for your own resource",
);
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
it("applies status and type filters", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -229,11 +287,110 @@ describe("vacation router", () => {
});
});
describe("background side effects", () => {
it("logs and swallows async notification failures during approval", async () => {
vi.mocked(createNotification).mockRejectedValueOnce(new Error("notification down"));
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
: {}),
};
}),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.PENDING,
}),
update: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(VacationStatus.APPROVED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({
effectName: "notifyVacationStatus",
vacationId: "vac_1",
resourceId: "res_1",
newStatus: VacationStatus.APPROVED,
}),
"Vacation background side effect failed",
);
});
it("logs and swallows webhook failures during approval", async () => {
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook down"));
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
: {}),
};
}),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.PENDING,
}),
update: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(VacationStatus.APPROVED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "vacation.approved" }),
"Vacation background side effect failed",
);
});
});
describe("getById", () => {
it("returns vacation by id", async () => {
const db = {
resource: {
findFirst: vi.fn(),
},
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
resource: { ...sampleVacation.resource, userId: "user_1" },
}),
},
};
@@ -248,6 +405,23 @@ describe("vacation router", () => {
);
});
it("forbids regular users from reading another user's vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
requestedById: "someone_else",
resource: { ...sampleVacation.resource, userId: "someone_else" },
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "vac_1" })).rejects.toThrow(
"You can only view your own vacation data",
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
@@ -890,6 +1064,9 @@ describe("vacation router", () => {
describe("getForResource", () => {
it("returns approved vacations in date range", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
@@ -920,6 +1097,27 @@ describe("vacation router", () => {
}),
);
});
it("forbids regular users from reading another resource's approved vacations", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getForResource({
resourceId: "res_other",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-12-31"),
}),
).rejects.toThrow("You can only view vacation data for your own resource");
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
});
describe("getPendingApprovals", () => {
@@ -952,6 +1150,7 @@ describe("vacation router", () => {
it("returns overlapping vacations for the same chapter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
},
vacation: {
@@ -987,6 +1186,7 @@ describe("vacation router", () => {
it("returns empty array when resource has no chapter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
},
};
@@ -1000,6 +1200,76 @@ describe("vacation router", () => {
expect(result).toEqual([]);
});
it("forbids regular users from reading another resource's team overlap", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
findUnique: vi.fn(),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getTeamOverlap({
resourceId: "res_other",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
}),
).rejects.toThrow("You can only view vacation data for your own resource");
expect(db.resource.findUnique).not.toHaveBeenCalled();
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
});
describe("getTeamOverlapDetail", () => {
it("returns assistant-friendly overlap detail from the canonical overlap query", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi
.fn()
.mockResolvedValueOnce({ displayName: "Bruce Banner", chapter: "CGI" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
...sampleVacation,
id: "vac_other",
resourceId: "res_2",
status: VacationStatus.APPROVED,
resource: { id: "res_2", displayName: "Clark Kent", eid: "E-002" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlapDetail({
resourceId: "res_1",
startDate: new Date("2026-08-10T00:00:00.000Z"),
endDate: new Date("2026-08-12T00:00:00.000Z"),
});
expect(result).toEqual({
resource: "Bruce Banner",
chapter: "CGI",
period: "2026-08-10 to 2026-08-12",
overlapCount: 1,
overlappingVacations: [
{
resource: "Clark Kent",
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
start: "2026-06-01",
end: "2026-06-05",
},
],
});
});
});
describe("batchCreatePublicHolidays", () => {