Files
Nexus/packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts
T

552 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listAssignmentBookings } from "@capakraken/application";
import { PermissionKey, SystemRole } from "@capakraken/shared";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
approveEstimateVersion: vi.fn(),
cloneEstimate: vi.fn(),
commitDispoImportBatch: vi.fn(),
countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }),
createEstimateExport: vi.fn(),
createEstimatePlanningHandoff: vi.fn(),
createEstimateRevision: vi.fn(),
assessDispoImportReadiness: vi.fn(),
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()),
getDashboardDemand: vi.fn().mockResolvedValue([]),
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
getDashboardOverview: vi.fn(),
getDashboardSkillGapSummary: vi.fn().mockResolvedValue({
roleGaps: [],
totalOpenPositions: 0,
skillSupplyTop10: [],
resourcesByRole: [],
}),
getDashboardProjectHealth: vi.fn().mockResolvedValue([]),
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
getDashboardTopValueResources: vi.fn().mockResolvedValue([]),
getEstimateById: vi.fn(),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
stageDispoImportBatch: vi.fn(),
submitEstimateVersion: vi.fn(),
updateEstimateDraft: vi.fn(),
};
});
import { executeTool } from "../router/assistant-tools.js";
import { createToolContext } from "./assistant-tools-insights-scenarios-test-helpers.js";
describe("assistant tools workflow scenarios", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
});
it("capacity search then resource check: finds capacity and then checks a specific resource from the result", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
const resourceRecord = {
id: "res_1",
displayName: "Bruce Banner",
eid: "EMP-001",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
countryId: "country_de",
federalState: null,
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
areaRole: { name: "Pipeline TD" },
chapter: "Delivery",
};
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([resourceRecord]),
findUnique: vi.fn().mockResolvedValue(resourceRecord),
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_PLANNING],
});
// Step 1: find capacity for the period
const capacityResult = await executeTool(
"find_capacity",
JSON.stringify({
startDate: "2026-05-01",
endDate: "2026-05-30",
minHoursPerDay: 4,
chapter: "Delivery",
}),
ctx,
);
const capacityParsed = JSON.parse(capacityResult.content) as {
results: Array<{ id: string; name: string; eid: string }>;
totalFound: number;
};
expect(db.resource.findMany).toHaveBeenCalled();
expect(capacityParsed.results).toHaveLength(1);
expect(capacityParsed.results[0].name).toBe("Bruce Banner");
// Step 2: use resource ID from result to check availability in a specific date window
const foundResourceId = capacityParsed.results[0].id;
expect(foundResourceId).toBe("res_1");
const availabilityResult = await executeTool(
"check_resource_availability",
JSON.stringify({
resourceId: foundResourceId,
startDate: "2026-05-05",
endDate: "2026-05-09",
}),
ctx,
);
const availabilityParsed = JSON.parse(availabilityResult.content) as {
workingDays: number;
periodAvailableHours: number;
periodBookedHours: number;
periodRemainingHours: number;
isFullyAvailable: boolean;
};
// 2026-05-05 (Tue) to 2026-05-09 (Sat) = 4 working days (Tue-Fri)
expect(availabilityParsed.workingDays).toBe(4);
expect(availabilityParsed.periodAvailableHours).toBe(32);
expect(availabilityParsed.periodBookedHours).toBe(0);
expect(availabilityParsed.periodRemainingHours).toBe(32);
expect(availabilityParsed.isFullyAvailable).toBe(true);
});
it("vacation balance then upcoming vacations: retrieves balance and then lists upcoming vacations for the same resource", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z"));
try {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "res_2",
eid: "EMP-002",
displayName: "Tony Stark",
userId: "user_2",
chapter: "Engineering",
federalState: null,
countryId: "country_de",
metroCityId: null,
country: { code: "DE", name: "Germany" },
metroCity: null,
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "ent_2026_res2",
resourceId: "res_2",
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
update: vi.fn().mockResolvedValue({
id: "ent_2026_res2",
resourceId: "res_2",
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
},
vacation: {
findMany: vi.fn().mockImplementation(async (args: { where?: { type?: unknown; status?: unknown; startDate?: unknown } } = {}) => {
if (args?.where?.status === "APPROVED" && args?.where?.startDate) {
// list_vacations_upcoming query
return [
{
id: "vac_1",
resourceId: "res_2",
status: "APPROVED",
type: "ANNUAL",
startDate: new Date("2026-05-10T00:00:00.000Z"),
endDate: new Date("2026-05-14T00:00:00.000Z"),
isHalfDay: false,
halfDayPart: null,
resource: {
id: "res_2",
displayName: "Tony Stark",
eid: "EMP-002",
lcrCents: 9_000,
chapter: "Engineering",
},
requestedBy: null,
approvedBy: null,
},
];
}
// vacation balance queries (by type)
return [];
}),
},
};
const ctx = createToolContext(db, { userRole: SystemRole.ADMIN });
// Step 1: get vacation balance for the resource
const balanceResult = await executeTool(
"get_vacation_balance",
JSON.stringify({ resourceId: "res_2", year: 2026 }),
ctx,
);
const balanceParsed = JSON.parse(balanceResult.content) as {
resource: string;
eid: string;
year: number;
entitlement: number;
remaining: number;
};
expect(balanceParsed.resource).toBe("Tony Stark");
expect(balanceParsed.eid).toBe("EMP-002");
expect(balanceParsed.year).toBe(2026);
expect(balanceParsed.entitlement).toBe(28);
expect(balanceParsed.remaining).toBe(28);
// Step 2: list upcoming vacations for the same resource (filter by name)
const upcomingResult = await executeTool(
"list_vacations_upcoming",
JSON.stringify({ resourceName: "Tony", daysAhead: 30, limit: 10 }),
ctx,
);
const upcomingParsed = JSON.parse(upcomingResult.content) as Array<{
resource: string;
eid: string;
start: string;
end: string;
}>;
expect(upcomingParsed).toHaveLength(1);
expect(upcomingParsed[0].resource).toBe("Tony Stark");
expect(upcomingParsed[0].eid).toBe("EMP-002");
expect(upcomingParsed[0].start).toBe("2026-05-10");
expect(upcomingParsed[0].end).toBe("2026-05-14");
} finally {
vi.useRealTimers();
}
});
it("demand listing then staffing suggestions: lists open demands and then retrieves suggestions for the project", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
const projectRecord = {
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
responsiblePerson: null,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-30T00:00:00.000Z"),
};
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-30T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "Pipeline TD",
roleId: null,
headcount: 2,
status: "OPEN",
metadata: null,
createdAt: new Date("2026-05-01T00:00:00.000Z"),
updatedAt: new Date("2026-05-01T00:00:00.000Z"),
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
endDate: new Date("2026-06-30T00:00:00.000Z"),
},
roleEntity: null,
assignments: [],
},
]),
},
project: {
findUnique: vi.fn().mockResolvedValue(projectRecord),
findFirst: vi.fn().mockResolvedValue(null),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_3",
displayName: "Natasha Romanoff",
eid: "EMP-003",
fte: 1,
chapter: "VFX",
skills: [{ skill: "Houdini", category: "FX", proficiency: 4, isMainSkill: true }],
lcrCents: 7_500,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
valueScore: 85,
countryId: null,
federalState: null,
metroCityId: null,
country: null,
metroCity: null,
areaRole: { name: "Pipeline TD" },
},
]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS],
});
// Step 1: list demands (no status filter — AllocationStatus enum doesn't include OPEN)
const demandsResult = await executeTool(
"list_demands",
JSON.stringify({}),
ctx,
);
const demandsParsed = JSON.parse(demandsResult.content) as Array<{
id: string;
project: string;
role: string;
status: string;
headcount: number;
remaining: number;
}>;
expect(demandsParsed).toHaveLength(1);
expect(demandsParsed[0].id).toBe("demand_1");
expect(demandsParsed[0].project).toBe("Gelddruckmaschine");
expect(demandsParsed[0].status).toBe("OPEN");
expect(demandsParsed[0].headcount).toBe(2);
expect(demandsParsed[0].remaining).toBe(2);
// Step 2: get staffing suggestions for the project from the demand
const projectId = demandsParsed[0].id.startsWith("demand_") ? "project_1" : demandsParsed[0].id;
const suggestionsResult = await executeTool(
"get_staffing_suggestions",
JSON.stringify({
projectId,
startDate: "2026-06-01",
endDate: "2026-06-30",
limit: 5,
}),
ctx,
);
const suggestionsParsed = JSON.parse(suggestionsResult.content) as {
project: string;
suggestions: Array<{ id: string; name: string }>;
};
expect(suggestionsParsed.project).toBe("Gelddruckmaschine (GDM)");
expect(Array.isArray(suggestionsParsed.suggestions)).toBe(true);
});
it("budget status check: retrieves budget utilization for a project", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
projectId: "project_2",
status: "CONFIRMED",
dailyCostCents: 12_000,
hoursPerDay: 8,
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-02T00:00:00.000Z"),
project: {
id: "project_2",
status: "ACTIVE",
},
},
{
projectId: "project_2",
status: "CONFIRMED",
dailyCostCents: 8_000,
hoursPerDay: 8,
startDate: new Date("2026-07-03T00:00:00.000Z"),
endDate: new Date("2026-07-03T00:00:00.000Z"),
project: {
id: "project_2",
status: "ACTIVE",
},
},
] as Awaited<ReturnType<typeof listAssignmentBookings>>);
const db = {
project: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "project_2",
name: "Raketenbauprojekt",
shortCode: "RBP",
status: "ACTIVE",
responsiblePerson: "Elon",
})
.mockResolvedValueOnce({
id: "project_2",
name: "Raketenbauprojekt",
shortCode: "RBP",
budgetCents: 500_000,
winProbability: 100,
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-31T00:00:00.000Z"),
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_COSTS],
});
const result = await executeTool(
"get_budget_status",
JSON.stringify({ projectId: "project_2" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
project: string;
code: string;
budget: string;
confirmed: string;
proposed: string;
allocated: string;
remaining: string;
utilization: string;
};
expect(vi.mocked(listAssignmentBookings)).toHaveBeenCalledWith(db, {
projectIds: ["project_2"],
});
expect(parsed.project).toBe("Raketenbauprojekt");
expect(parsed.code).toBe("RBP");
expect(parsed.budget).toBe("5.000,00 EUR");
// Booking 1: July 1-2 (2 days) × 12000 cents = 24000 cents = 240 EUR
// Booking 2: July 3 (1 day) × 8000 cents = 8000 cents = 80 EUR
// Total confirmed: 320 EUR
expect(parsed.confirmed).toBe("320,00 EUR");
expect(parsed.proposed).toBe("0,00 EUR");
expect(parsed.allocated).toBe("320,00 EUR");
expect(parsed.remaining).toBe("4.680,00 EUR");
expect(parsed.utilization).toBe("6.4%");
});
it("search resources with availability filter: finds resources in a chapter", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_4",
eid: "EMP-004",
displayName: "Wanda Maximoff",
chapter: "FX",
fte: 1,
lcrCents: 8_000,
chargeabilityTarget: 80,
isActive: true,
areaRole: { name: "FX Artist" },
country: { code: "DE", name: "Germany" },
metroCity: null,
orgUnit: null,
},
{
id: "res_5",
eid: "EMP-005",
displayName: "Pietro Maximoff",
chapter: "FX",
fte: 0.8,
lcrCents: 7_200,
chargeabilityTarget: 75,
isActive: true,
areaRole: { name: "Motion Designer" },
country: { code: "DE", name: "Germany" },
metroCity: null,
orgUnit: null,
},
]),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_ALL_RESOURCES],
});
const result = await executeTool(
"search_resources",
JSON.stringify({ orgUnit: undefined, country: "DE", limit: 10 }),
ctx,
);
const parsed = JSON.parse(result.content) as Array<{
id: string;
name: string;
eid: string;
chapter: string;
role: string;
}>;
expect(db.resource.findMany).toHaveBeenCalled();
expect(parsed).toHaveLength(2);
const wanda = parsed.find((r) => r.id === "res_4");
expect(wanda).toBeDefined();
expect(wanda?.name).toBe("Wanda Maximoff");
expect(wanda?.eid).toBe("EMP-004");
expect(wanda?.chapter).toBe("FX");
expect(wanda?.role).toBe("FX Artist");
const pietro = parsed.find((r) => r.id === "res_5");
expect(pietro).toBeDefined();
expect(pietro?.name).toBe("Pietro Maximoff");
expect(pietro?.eid).toBe("EMP-005");
});
});