1077 lines
32 KiB
TypeScript
1077 lines
32 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { SystemRole } from "@capakraken/shared";
|
|
|
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
|
return {
|
|
...actual,
|
|
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
|
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
|
};
|
|
});
|
|
|
|
vi.mock("../lib/audit.js", () => ({
|
|
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
|
|
|
function createToolContext(
|
|
db: Record<string, unknown>,
|
|
permissions: string[] = [],
|
|
userRole: SystemRole = SystemRole.ADMIN,
|
|
): ToolContext {
|
|
return {
|
|
db: db as ToolContext["db"],
|
|
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 holiday tools", () => {
|
|
it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
|
|
const ctx = createToolContext({});
|
|
|
|
const bavaria = await executeTool(
|
|
"list_holidays_by_region",
|
|
JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
|
|
ctx,
|
|
);
|
|
const hamburg = await executeTool(
|
|
"list_holidays_by_region",
|
|
JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
|
|
ctx,
|
|
);
|
|
|
|
const bavariaResult = JSON.parse(bavaria.content) as {
|
|
count: number;
|
|
locationContext: { federalState: string | null };
|
|
summary: { byScope: Array<{ scope: string; count: number }> };
|
|
holidays: Array<{ name: string; date: string }>;
|
|
};
|
|
const hamburgResult = JSON.parse(hamburg.content) as {
|
|
count: number;
|
|
locationContext: { federalState: string | null };
|
|
holidays: Array<{ name: string; date: string }>;
|
|
};
|
|
|
|
expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
|
|
expect(bavariaResult.locationContext.federalState).toBe("BY");
|
|
expect(bavariaResult.summary.byScope).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
|
|
);
|
|
expect(bavariaResult.holidays).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
|
]),
|
|
);
|
|
expect(hamburgResult.holidays).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("resolves resource-specific holidays including city-local dates", async () => {
|
|
const db = {
|
|
resource: {
|
|
findUnique: vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner" })
|
|
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
|
|
findFirst: vi.fn(),
|
|
},
|
|
};
|
|
const ctx = createToolContext(db);
|
|
|
|
const result = await executeTool(
|
|
"get_resource_holidays",
|
|
JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
resource: { eid: string; federalState: string | null; metroCity: string | null };
|
|
summary: { byScope: Array<{ scope: string; count: number }> };
|
|
holidays: Array<{ name: string; date: string }>;
|
|
};
|
|
|
|
expect(parsed.resource).toEqual(
|
|
expect.objectContaining({
|
|
eid: "bruce.banner",
|
|
federalState: "BY",
|
|
metroCity: "Augsburg",
|
|
}),
|
|
);
|
|
expect(parsed.holidays).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
|
|
]),
|
|
);
|
|
expect(parsed.summary.byScope).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
|
|
);
|
|
});
|
|
|
|
it("lists holiday calendars with scope metadata and entry counts", async () => {
|
|
const ctx = createToolContext({
|
|
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 result = await executeTool(
|
|
"list_holiday_calendars",
|
|
JSON.stringify({ countryCode: "DE", scopeType: "STATE", includeInactive: true }),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
count: number;
|
|
calendars: Array<{
|
|
name: string;
|
|
scopeType: string;
|
|
stateCode: string | null;
|
|
entryCount: number;
|
|
country: { code: string };
|
|
}>;
|
|
};
|
|
|
|
expect(parsed.count).toBe(1);
|
|
expect(parsed.calendars).toHaveLength(1);
|
|
expect(parsed.calendars[0]).toMatchObject({
|
|
name: "Bayern Feiertage",
|
|
scopeType: "STATE",
|
|
stateCode: "BY",
|
|
entryCount: 2,
|
|
country: { code: "DE" },
|
|
});
|
|
});
|
|
|
|
it("previews resolved holiday calendars for a scope and shows the source calendar", async () => {
|
|
const ctx = createToolContext({
|
|
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: 5,
|
|
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 result = await executeTool(
|
|
"preview_resolved_holiday_calendar",
|
|
JSON.stringify({ countryId: "country_de", metroCityId: "city_augsburg", year: 2026 }),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
count: number;
|
|
locationContext: { countryCode: string; metroCity: string | null; year: number };
|
|
holidays: Array<{ name: string; calendarName: string; scope: string; date: string }>;
|
|
};
|
|
|
|
expect(parsed.count).toBeGreaterThan(0);
|
|
expect(parsed.locationContext).toEqual(
|
|
expect.objectContaining({
|
|
countryCode: "DE",
|
|
metroCity: "Augsburg",
|
|
year: 2026,
|
|
}),
|
|
);
|
|
expect(parsed.holidays).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "Friedensfest lokal",
|
|
calendarName: "Augsburg lokal",
|
|
scope: "CITY",
|
|
date: "2026-08-08",
|
|
}),
|
|
]),
|
|
);
|
|
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 () => {
|
|
const ctx = createToolContext({
|
|
country: {
|
|
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
|
|
},
|
|
holidayCalendar: {
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
create: 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,
|
|
entries: [],
|
|
}),
|
|
},
|
|
});
|
|
|
|
const result = await executeTool(
|
|
"create_holiday_calendar",
|
|
JSON.stringify({
|
|
name: "Bayern Feiertage",
|
|
scopeType: "STATE",
|
|
countryId: "country_de",
|
|
stateCode: "BY",
|
|
priority: 10,
|
|
}),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
success: boolean;
|
|
message: string;
|
|
calendar: { name: string; stateCode: string | null };
|
|
};
|
|
|
|
expect(parsed.success).toBe(true);
|
|
expect(parsed.message).toContain("Created holiday calendar");
|
|
expect(parsed.calendar).toEqual(
|
|
expect.objectContaining({
|
|
name: "Bayern Feiertage",
|
|
stateCode: "BY",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects holiday calendar mutations for non-admin assistant users", async () => {
|
|
const ctx = createToolContext({}, [], SystemRole.MANAGER);
|
|
const result = await executeTool(
|
|
"create_holiday_calendar",
|
|
JSON.stringify({
|
|
name: "Hamburg Feiertage",
|
|
scopeType: "STATE",
|
|
countryId: "country_de",
|
|
stateCode: "HH",
|
|
}),
|
|
ctx,
|
|
);
|
|
|
|
expect(JSON.parse(result.content)).toEqual(
|
|
expect.objectContaining({
|
|
error: "Admin role required to perform this action.",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns stable assistant errors for holiday calendar and entry mutations", async () => {
|
|
const cases = [
|
|
{
|
|
name: "invalid holiday calendar scope",
|
|
toolName: "create_holiday_calendar",
|
|
db: {
|
|
country: {
|
|
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
|
|
},
|
|
holidayCalendar: {
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
},
|
|
},
|
|
payload: {
|
|
name: "Ungueltiger Kalender",
|
|
scopeType: "STATE",
|
|
countryId: "country_de",
|
|
},
|
|
expected: "Holiday calendar scope is invalid.",
|
|
},
|
|
{
|
|
name: "duplicate holiday calendar scope",
|
|
toolName: "create_holiday_calendar",
|
|
db: {
|
|
country: {
|
|
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
|
|
},
|
|
holidayCalendar: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "cal_existing" }),
|
|
},
|
|
},
|
|
payload: {
|
|
name: "Bayern Feiertage",
|
|
scopeType: "STATE",
|
|
countryId: "country_de",
|
|
stateCode: "BY",
|
|
},
|
|
expected: "A holiday calendar for this scope already exists.",
|
|
},
|
|
{
|
|
name: "holiday calendar not found on delete",
|
|
toolName: "delete_holiday_calendar",
|
|
db: {
|
|
holidayCalendar: {
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
},
|
|
},
|
|
payload: { id: "cal_missing" },
|
|
expected: "Holiday calendar not found with the given criteria.",
|
|
},
|
|
{
|
|
name: "holiday calendar entry not found on delete",
|
|
toolName: "delete_holiday_calendar_entry",
|
|
db: {
|
|
holidayCalendarEntry: {
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
},
|
|
},
|
|
payload: { id: "entry_missing" },
|
|
expected: "Holiday calendar entry not found with the given criteria.",
|
|
},
|
|
{
|
|
name: "duplicate holiday calendar entry date",
|
|
toolName: "create_holiday_calendar_entry",
|
|
db: {
|
|
holidayCalendar: {
|
|
findUnique: vi.fn().mockResolvedValue({ id: "cal_by", name: "Bayern Feiertage" }),
|
|
},
|
|
holidayCalendarEntry: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }),
|
|
},
|
|
},
|
|
payload: {
|
|
holidayCalendarId: "cal_by",
|
|
date: "2026-01-06",
|
|
name: "Heilige Drei Koenige",
|
|
},
|
|
expected: "A holiday entry for this calendar and date already exists.",
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const result = await executeTool(
|
|
testCase.toolName,
|
|
JSON.stringify(testCase.payload),
|
|
createToolContext(testCase.db),
|
|
);
|
|
|
|
expect(JSON.parse(result.content)).toEqual({
|
|
error: testCase.expected,
|
|
});
|
|
}
|
|
});
|
|
|
|
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(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: {
|
|
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);
|
|
|
|
const result = await executeTool(
|
|
"get_chargeability",
|
|
JSON.stringify({ resourceId: "res_1", month: "2026-01" }),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
baseWorkingDays: number;
|
|
baseAvailableHours: number;
|
|
availableHours: number;
|
|
bookedHours: number;
|
|
workingDays: number;
|
|
targetHours: number;
|
|
unassignedHours: number;
|
|
holidaySummary: { count: number; workdayCount: number; hoursDeduction: number };
|
|
capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number };
|
|
locationContext: { federalState: string | null };
|
|
allocations: Array<{ hours: number }>;
|
|
};
|
|
|
|
expect(parsed.bookedHours).toBe(8);
|
|
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
|
|
expect(parsed.baseWorkingDays).toBe(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({
|
|
count: 2,
|
|
workdayCount: 2,
|
|
hoursDeduction: 16,
|
|
}),
|
|
);
|
|
expect(parsed.capacityBreakdown).toEqual(
|
|
expect.objectContaining({
|
|
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
|
|
holidayHoursDeduction: 16,
|
|
absenceHoursDeduction: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
|
|
const { getDashboardBudgetForecast } = await import("@capakraken/application");
|
|
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
|
{
|
|
projectId: "project_1",
|
|
projectName: "Gelddruckmaschine",
|
|
shortCode: "GDM",
|
|
budgetCents: 100_000,
|
|
spentCents: 60_000,
|
|
burnRate: 5_000,
|
|
pctUsed: 60,
|
|
estimatedExhaustionDate: "2026-02-20",
|
|
},
|
|
]);
|
|
|
|
const ctx = createToolContext({}, ["viewCosts"]);
|
|
const result = await executeTool("get_budget_forecast", "{}", ctx);
|
|
const parsed = JSON.parse(result.content) as {
|
|
forecasts: Array<{
|
|
projectName: string;
|
|
shortCode: string;
|
|
budgetCents: number;
|
|
spentCents: number;
|
|
remainingCents: number;
|
|
projectedCents: number;
|
|
burnRateCents: number;
|
|
burnStatus: string;
|
|
}>;
|
|
};
|
|
|
|
expect(parsed.forecasts).toEqual([
|
|
expect.objectContaining({
|
|
projectName: "Gelddruckmaschine",
|
|
shortCode: "GDM",
|
|
budgetCents: 100_000,
|
|
spentCents: 60_000,
|
|
remainingCents: 40_000,
|
|
projectedCents: 100_000,
|
|
burnRateCents: 5_000,
|
|
burnStatus: "on_track",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("checks resource availability with regional holidays excluded from capacity", async () => {
|
|
const 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()
|
|
.mockResolvedValue(resourceRecord),
|
|
findFirst: vi.fn(),
|
|
},
|
|
assignment: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
hoursPerDay: 8,
|
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
status: "CONFIRMED",
|
|
project: { name: "Gamma", shortCode: "GAM" },
|
|
},
|
|
]),
|
|
},
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
};
|
|
const ctx = createToolContext(db);
|
|
|
|
const result = await executeTool(
|
|
"check_resource_availability",
|
|
JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
workingDays: number;
|
|
periodAvailableHours: number;
|
|
periodBookedHours: number;
|
|
periodRemainingHours: number;
|
|
availableHoursPerDay: number;
|
|
isFullyAvailable: boolean;
|
|
};
|
|
|
|
expect(parsed.workingDays).toBe(1);
|
|
expect(parsed.periodAvailableHours).toBe(8);
|
|
expect(parsed.periodBookedHours).toBe(8);
|
|
expect(parsed.periodRemainingHours).toBe(0);
|
|
expect(parsed.availableHoursPerDay).toBe(0);
|
|
expect(parsed.isFullyAvailable).toBe(false);
|
|
});
|
|
|
|
it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => {
|
|
const db = {
|
|
project: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: "project_1",
|
|
name: "Holiday Project",
|
|
budgetCents: 500_000,
|
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
|
}),
|
|
},
|
|
assignment: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "assignment_1",
|
|
resourceId: "res_1",
|
|
hoursPerDay: 8,
|
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
|
status: "CONFIRMED",
|
|
resource: {
|
|
id: "res_1",
|
|
displayName: "Bruce Banner",
|
|
lcrCents: 100,
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
chargeabilityTarget: 80,
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: null,
|
|
country: { code: "DE", dailyWorkingHours: 8 },
|
|
metroCity: null,
|
|
},
|
|
},
|
|
]),
|
|
},
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_1",
|
|
displayName: "Bruce Banner",
|
|
lcrCents: 100,
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
chargeabilityTarget: 80,
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: null,
|
|
country: { code: "DE", dailyWorkingHours: 8 },
|
|
metroCity: null,
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
const ctx = createToolContext(db, ["manageAllocations"]);
|
|
|
|
const result = await executeTool(
|
|
"simulate_scenario",
|
|
JSON.stringify({
|
|
projectId: "project_1",
|
|
changes: [
|
|
{
|
|
resourceId: "res_1",
|
|
startDate: "2026-01-06",
|
|
endDate: "2026-01-06",
|
|
hoursPerDay: 8,
|
|
},
|
|
],
|
|
}),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
baseline: { totalHours: number; totalCostCents: number };
|
|
scenario: { totalHours: number; totalCostCents: number };
|
|
delta: { hours: number; costCents: number };
|
|
};
|
|
|
|
expect(parsed.baseline).toEqual(
|
|
expect.objectContaining({
|
|
totalHours: 8,
|
|
totalCostCents: 800,
|
|
}),
|
|
);
|
|
expect(parsed.scenario).toEqual(
|
|
expect.objectContaining({
|
|
totalHours: 8,
|
|
totalCostCents: 800,
|
|
}),
|
|
);
|
|
expect(parsed.delta).toEqual(
|
|
expect.objectContaining({
|
|
hours: 0,
|
|
costCents: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("prefers resources without a local holiday in staffing suggestions", async () => {
|
|
const db = {
|
|
project: {
|
|
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([
|
|
{
|
|
id: "res_by",
|
|
displayName: "Bavaria",
|
|
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", name: "Deutschland" },
|
|
metroCity: null,
|
|
areaRole: { name: "Consultant" },
|
|
chapter: "CGI",
|
|
},
|
|
{
|
|
id: "res_hh",
|
|
displayName: "Hamburg",
|
|
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", name: "Deutschland" },
|
|
metroCity: null,
|
|
areaRole: { name: "Consultant" },
|
|
chapter: "CGI",
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
const ctx = createToolContext(db);
|
|
|
|
const result = await executeTool(
|
|
"get_staffing_suggestions",
|
|
JSON.stringify({ projectId: "project_1", limit: 5 }),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
suggestions: Array<{ name: string; availableHours: number }>;
|
|
};
|
|
|
|
expect(parsed.suggestions).toHaveLength(1);
|
|
expect(parsed.suggestions[0]).toEqual(
|
|
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
|
|
);
|
|
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 () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_by",
|
|
displayName: "Bavaria",
|
|
eid: "BY-1",
|
|
fte: 1,
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: null,
|
|
country: { code: "DE" },
|
|
metroCity: null,
|
|
areaRole: { name: "Consultant" },
|
|
chapter: "CGI",
|
|
assignments: [],
|
|
},
|
|
{
|
|
id: "res_hh",
|
|
displayName: "Hamburg",
|
|
eid: "HH-1",
|
|
fte: 1,
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
countryId: "country_de",
|
|
federalState: "HH",
|
|
metroCityId: null,
|
|
country: { code: "DE" },
|
|
metroCity: null,
|
|
areaRole: { name: "Consultant" },
|
|
chapter: "CGI",
|
|
assignments: [],
|
|
},
|
|
]),
|
|
},
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
};
|
|
const ctx = createToolContext(db);
|
|
|
|
const result = await executeTool(
|
|
"find_capacity",
|
|
JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }),
|
|
ctx,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content) as {
|
|
results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>;
|
|
};
|
|
|
|
expect(parsed.results).toHaveLength(1);
|
|
expect(parsed.results[0]).toEqual(
|
|
expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }),
|
|
);
|
|
});
|
|
|
|
it("returns a stable assistant error when staffing suggestions receive an invalid optional start date", async () => {
|
|
const db = {
|
|
project: {
|
|
findUnique: vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({
|
|
id: "project_1",
|
|
name: "Holiday Project",
|
|
shortCode: "HP",
|
|
status: "ACTIVE",
|
|
responsiblePerson: null,
|
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
|
}),
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
},
|
|
};
|
|
const ctx = createToolContext(db);
|
|
|
|
const result = await executeTool(
|
|
"get_staffing_suggestions",
|
|
JSON.stringify({ projectId: "project_1", startDate: "2026-99-01" }),
|
|
ctx,
|
|
);
|
|
|
|
expect(JSON.parse(result.content)).toEqual({
|
|
error: "Invalid startDate: 2026-99-01",
|
|
});
|
|
});
|
|
|
|
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
|
|
const db = {
|
|
project: {
|
|
findUnique: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
id: "project_1",
|
|
name: "Holiday Project",
|
|
shortCode: "HP",
|
|
status: "ACTIVE",
|
|
responsiblePerson: null,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: "project_1",
|
|
name: "Holiday Project",
|
|
shoringThreshold: 55,
|
|
onshoreCountryCode: "DE",
|
|
}),
|
|
},
|
|
assignment: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
resourceId: "res_by",
|
|
hoursPerDay: 8,
|
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
resource: {
|
|
id: "res_by",
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: null,
|
|
country: { code: "DE" },
|
|
metroCity: null,
|
|
},
|
|
},
|
|
{
|
|
resourceId: "res_in",
|
|
hoursPerDay: 8,
|
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
resource: {
|
|
id: "res_in",
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
countryId: "country_in",
|
|
federalState: null,
|
|
metroCityId: null,
|
|
country: { code: "IN" },
|
|
metroCity: null,
|
|
},
|
|
},
|
|
]),
|
|
},
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
};
|
|
const ctx = createToolContext(db);
|
|
|
|
const result = await executeTool(
|
|
"get_shoring_ratio",
|
|
JSON.stringify({ projectId: "project_1" }),
|
|
ctx,
|
|
);
|
|
|
|
expect(result.content).toContain("0% onshore (DE), 100% offshore");
|
|
expect(result.content).toContain("IN 100% (1 people)");
|
|
});
|
|
|
|
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",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
});
|