feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user