test(api): harden assistant tool error handling
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
|
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
@@ -893,6 +894,75 @@ describe("assistant advanced tools and scoping", () => {
|
|||||||
expect(db.assignment.create).toHaveBeenCalled();
|
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 () => {
|
it("batch quick-assigns timeline resources through the real timeline router mutation", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
@@ -975,6 +1045,60 @@ describe("assistant advanced tools and scoping", () => {
|
|||||||
expect(db.assignment.create).toHaveBeenCalledTimes(2);
|
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 () => {
|
it("applies timeline project shifts through the real timeline router mutation", async () => {
|
||||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||||
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
|
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 () => {
|
it("batch-shifts timeline allocations through the real timeline router mutation", async () => {
|
||||||
const existingAssignment = {
|
const existingAssignment = {
|
||||||
id: "assignment_1",
|
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 () => {
|
it("returns the chargeability report readmodel through the assistant", async () => {
|
||||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
@@ -1494,7 +1735,7 @@ describe("assistant advanced tools and scoping", () => {
|
|||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
error: expect.stringContaining("Admin role required"),
|
error: "You do not have permission to perform this action.",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(findMany).not.toHaveBeenCalled();
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ describe("assistant audit tools", () => {
|
|||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
expect.objectContaining({
|
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 () => {
|
it("creates a country for admin users and returns an invalidation action", async () => {
|
||||||
const ctx = createToolContext({
|
const ctx = createToolContext({
|
||||||
country: {
|
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 () => {
|
it("refuses country mutations for non-admin users", async () => {
|
||||||
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
|
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
|
||||||
|
|
||||||
@@ -203,4 +262,44 @@ describe("assistant country tools", () => {
|
|||||||
message: "Deleted metro city: Hamburg",
|
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 () => {
|
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
||||||
const resourceRecord = {
|
const resourceRecord = {
|
||||||
id: "res_1",
|
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 () => {
|
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -619,6 +619,13 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
senderId,
|
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
|
// 4. Create individual notifications for each recipient
|
||||||
const isTask = input.category === "TASK" || input.category === "APPROVAL";
|
const isTask = input.category === "TASK" || input.category === "APPROVAL";
|
||||||
|
|
||||||
|
|||||||
@@ -135,10 +135,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = await ctx.db.user.findUniqueOrThrow({
|
const user = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
const { hash } = await import("@node-rs/argon2");
|
const { hash } = await import("@node-rs/argon2");
|
||||||
const passwordHash = await hash(input.password);
|
const passwordHash = await hash(input.password);
|
||||||
@@ -170,10 +173,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const before = await ctx.db.user.findUniqueOrThrow({
|
const before = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
select: { id: true, name: true, email: true, systemRole: true },
|
select: { id: true, name: true, email: true, systemRole: true },
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
const updated = await ctx.db.user.update({
|
const updated = await ctx.db.user.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
@@ -205,10 +211,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const before = await ctx.db.user.findUniqueOrThrow({
|
const before = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
const updated = await ctx.db.user.update({
|
const updated = await ctx.db.user.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
@@ -237,7 +246,23 @@ export const userRouter = createTRPCRouter({
|
|||||||
linkResource: adminProcedure
|
linkResource: adminProcedure
|
||||||
.input(z.object({ userId: z.string(), resourceId: z.string().nullable() }))
|
.input(z.object({ userId: z.string(), resourceId: z.string().nullable() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
|
where: { id: input.userId },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
if (input.resourceId) {
|
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
|
// Unlink any resource previously linked to this user
|
||||||
await ctx.db.resource.updateMany({
|
await ctx.db.resource.updateMany({
|
||||||
where: { userId: input.userId },
|
where: { userId: input.userId },
|
||||||
@@ -345,10 +370,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const before = await ctx.db.user.findUniqueOrThrow({
|
const before = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
select: { id: true, name: true, email: true, permissionOverrides: true },
|
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
const user = await ctx.db.user.update({
|
const user = await ctx.db.user.update({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
@@ -376,10 +404,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
resetPermissions: adminProcedure
|
resetPermissions: adminProcedure
|
||||||
.input(z.object({ userId: z.string() }))
|
.input(z.object({ userId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const before = await ctx.db.user.findUniqueOrThrow({
|
const before = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
select: { id: true, name: true, email: true, permissionOverrides: true },
|
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
const updated = await ctx.db.user.update({
|
const updated = await ctx.db.user.update({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
@@ -453,10 +484,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
getEffectivePermissions: adminProcedure
|
getEffectivePermissions: adminProcedure
|
||||||
.input(z.object({ userId: z.string() }))
|
.input(z.object({ userId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const user = await ctx.db.user.findUniqueOrThrow({
|
const user = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
select: { systemRole: true, permissionOverrides: true },
|
select: { systemRole: true, permissionOverrides: true },
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
const permissions = resolvePermissions(
|
const permissions = resolvePermissions(
|
||||||
user.systemRole as SystemRole,
|
user.systemRole as SystemRole,
|
||||||
user.permissionOverrides as PermissionOverrides | null,
|
user.permissionOverrides as PermissionOverrides | null,
|
||||||
@@ -547,10 +581,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
disableTotp: adminProcedure
|
disableTotp: adminProcedure
|
||||||
.input(z.object({ userId: z.string() }))
|
.input(z.object({ userId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = await ctx.db.user.findUniqueOrThrow({
|
const user = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
select: { id: true, name: true, email: true, totpEnabled: true },
|
select: { id: true, name: true, email: true, totpEnabled: true },
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.db.user.update({
|
await ctx.db.user.update({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
|
|||||||
Reference in New Issue
Block a user