test(api): harden assistant tool error handling

This commit is contained in:
2026-03-30 11:51:59 +02:00
parent 4ce8577824
commit 7aa32f8a5c
8 changed files with 7898 additions and 381 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
@@ -893,6 +894,75 @@ describe("assistant advanced tools and scoping", () => {
expect(db.assignment.create).toHaveBeenCalled();
});
it("returns a stable conflict error for quick-assign timeline mutations", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: null,
}),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
eid: "E-001",
displayName: "Alice",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockRejectedValue(
new TRPCError({
code: "CONFLICT",
message: "Resource is already assigned to this project with overlapping dates",
}),
),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"quick_assign_timeline_resource",
JSON.stringify({
resourceIdentifier: "resource_1",
projectIdentifier: "project_1",
startDate: "2026-03-16",
endDate: "2026-03-20",
hoursPerDay: 8,
}),
ctx,
);
expect(result.action).toBeUndefined();
expect(JSON.parse(result.content)).toEqual({
error: "Resource is already assigned to this project with overlapping dates",
});
});
it("batch quick-assigns timeline resources through the real timeline router mutation", async () => {
const db = {
project: {
@@ -975,6 +1045,60 @@ describe("assistant advanced tools and scoping", () => {
expect(db.assignment.create).toHaveBeenCalledTimes(2);
});
it("returns a structured batch assignment resolver error for an unknown resource", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
responsiblePerson: null,
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
}),
findFirst: vi.fn().mockResolvedValue(null),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
create: vi.fn(),
},
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"batch_quick_assign_timeline_resources",
JSON.stringify({
assignments: [
{
resourceIdentifier: "missing_resource",
projectIdentifier: "project_1",
startDate: "2026-03-16",
endDate: "2026-03-20",
hoursPerDay: 8,
},
],
}),
ctx,
);
expect(result.action).toBeUndefined();
expect(JSON.parse(result.content)).toEqual({
error: "assignments[0].resourceIdentifier: Resource not found: missing_resource",
field: "assignments[0].resourceIdentifier",
index: 0,
});
expect(db.assignment.create).not.toHaveBeenCalled();
});
it("applies timeline project shifts through the real timeline router mutation", async () => {
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
@@ -1046,6 +1170,56 @@ describe("assistant advanced tools and scoping", () => {
);
});
it("returns a stable project-not-found error if the timeline shift target disappears mid-mutation", async () => {
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
project: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: null,
budgetCents: 100000,
winProbability: 100,
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
})
.mockResolvedValueOnce(null),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"apply_timeline_project_shift",
JSON.stringify({
projectIdentifier: "project_1",
newStartDate: "2026-03-23",
newEndDate: "2026-03-27",
}),
ctx,
);
expect(result.action).toBeUndefined();
expect(JSON.parse(result.content)).toEqual({
error: "Project not found with the given criteria.",
});
});
it("batch-shifts timeline allocations through the real timeline router mutation", async () => {
const existingAssignment = {
id: "assignment_1",
@@ -1133,6 +1307,73 @@ describe("assistant advanced tools and scoping", () => {
);
});
it("returns a stable allocation-not-found error for inline timeline updates", async () => {
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"update_timeline_allocation_inline",
JSON.stringify({
allocationId: "assignment_missing",
hoursPerDay: 6,
}),
ctx,
);
expect(result.action).toBeUndefined();
expect(JSON.parse(result.content)).toEqual({
error: "Allocation not found with the given criteria.",
});
});
it("returns a stable allocation-not-found error for batch timeline shifts without matches", async () => {
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"batch_shift_timeline_allocations",
JSON.stringify({
allocationIds: ["assignment_missing"],
daysDelta: 2,
mode: "move",
}),
ctx,
);
expect(result.action).toBeUndefined();
expect(JSON.parse(result.content)).toEqual({
error: "Allocation not found with the given criteria.",
});
});
it("returns the chargeability report readmodel through the assistant", async () => {
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValue([
@@ -1494,7 +1735,7 @@ describe("assistant advanced tools and scoping", () => {
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
error: expect.stringContaining("Admin role required"),
error: "You do not have permission to perform this action.",
}),
);
expect(findMany).not.toHaveBeenCalled();
@@ -122,7 +122,7 @@ describe("assistant audit tools", () => {
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
error: expect.stringContaining("Controller access required"),
error: "You do not have permission to perform this action.",
}),
);
});
@@ -129,6 +129,25 @@ describe("assistant country tools", () => {
});
});
it("returns a stable error when a country cannot be resolved", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
},
});
const result = await executeTool(
"get_country",
JSON.stringify({ identifier: "Atlantis" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Country not found with the given criteria.",
});
});
it("creates a country for admin users and returns an invalidation action", async () => {
const ctx = createToolContext({
country: {
@@ -162,6 +181,46 @@ describe("assistant country tools", () => {
});
});
it("returns a stable error when creating a country with a duplicate code", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue({
id: "country_es_existing",
code: "ES",
name: "Existing Spain",
}),
},
});
const result = await executeTool(
"create_country",
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "A country with this code already exists.",
});
});
it("returns a stable error when updating a missing country", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const result = await executeTool(
"update_country",
JSON.stringify({ id: "country_missing", data: { name: "Atlantis" } }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Country not found with the given criteria.",
});
});
it("refuses country mutations for non-admin users", async () => {
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
@@ -203,4 +262,44 @@ describe("assistant country tools", () => {
message: "Deleted metro city: Hamburg",
});
});
it("returns a stable error when updating a missing metro city", async () => {
const ctx = createToolContext({
metroCity: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const result = await executeTool(
"update_metro_city",
JSON.stringify({ id: "city_missing", data: { name: "Hamburg-Mitte" } }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Metro city not found with the given criteria.",
});
});
it("returns a stable error when deleting a metro city that is still assigned", async () => {
const ctx = createToolContext({
metroCity: {
findUnique: vi.fn().mockResolvedValue({
id: "city_ham",
name: "Hamburg",
_count: { resources: 3 },
}),
},
});
const result = await executeTool(
"delete_metro_city",
JSON.stringify({ id: "city_ham" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Metro city cannot be deleted while it is still assigned to resources.",
});
});
});
@@ -320,6 +320,100 @@ describe("assistant holiday tools", () => {
);
});
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",
@@ -763,6 +857,37 @@ describe("assistant holiday tools", () => {
);
});
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: {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -619,6 +619,13 @@ export const notificationRouter = createTRPCRouter({
senderId,
);
if (recipientIds.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No recipients matched the broadcast target.",
});
}
// 4. Create individual notifications for each recipient
const isTask = input.category === "TASK" || input.category === "APPROVAL";
+65 -28
View File
@@ -135,10 +135,13 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { id: true, name: true, email: true },
});
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true },
}),
"User",
);
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);
@@ -170,10 +173,13 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const before = await ctx.db.user.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, email: true, systemRole: true },
});
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, email: true, systemRole: true },
}),
"User",
);
const updated = await ctx.db.user.update({
where: { id: input.id },
@@ -205,10 +211,13 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const before = await ctx.db.user.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, email: true },
});
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, email: true },
}),
"User",
);
const updated = await ctx.db.user.update({
where: { id: input.id },
@@ -237,7 +246,23 @@ export const userRouter = createTRPCRouter({
linkResource: adminProcedure
.input(z.object({ userId: z.string(), resourceId: z.string().nullable() }))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true },
}),
"User",
);
if (input.resourceId) {
await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { id: true },
}),
"Resource",
);
// Unlink any resource previously linked to this user
await ctx.db.resource.updateMany({
where: { userId: input.userId },
@@ -345,10 +370,13 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const before = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { id: true, name: true, email: true, permissionOverrides: true },
});
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, permissionOverrides: true },
}),
"User",
);
const user = await ctx.db.user.update({
where: { id: input.userId },
@@ -376,10 +404,13 @@ export const userRouter = createTRPCRouter({
resetPermissions: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const before = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { id: true, name: true, email: true, permissionOverrides: true },
});
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, permissionOverrides: true },
}),
"User",
);
const updated = await ctx.db.user.update({
where: { id: input.userId },
@@ -453,10 +484,13 @@ export const userRouter = createTRPCRouter({
getEffectivePermissions: adminProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { systemRole: true, permissionOverrides: true },
});
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { systemRole: true, permissionOverrides: true },
}),
"User",
);
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as PermissionOverrides | null,
@@ -547,10 +581,13 @@ export const userRouter = createTRPCRouter({
disableTotp: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { id: true, name: true, email: true, totpEnabled: true },
});
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, totpEnabled: true },
}),
"User",
);
await ctx.db.user.update({
where: { id: input.userId },