feat(api): add audit helpers, tool registry, shared tool manifest types, and UI primitives
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user