test(api): cover remaining timeline and broadcast fallback races

This commit is contained in:
2026-03-30 12:23:46 +02:00
parent a9a01e8df0
commit 732538857b
2 changed files with 449 additions and 0 deletions
@@ -963,6 +963,241 @@ describe("assistant advanced tools and scoping", () => {
}); });
}); });
it("returns stable not-found errors when quick-assign timeline targets disappear mid-mutation", async () => {
const baseProject = {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: null,
};
const baseResource = {
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,
},
};
const cases = [
{
name: "missing project",
tx: {
project: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findUnique: vi.fn().mockResolvedValue(baseResource),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
},
expected: "Project not found with the given criteria.",
},
{
name: "missing resource",
tx: {
project: {
findUnique: vi.fn().mockResolvedValue(baseProject),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
},
expected: "Resource not found with the given criteria.",
},
] as const;
for (const testCase of cases) {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue(baseProject),
},
resource: {
findUnique: vi.fn().mockResolvedValue(baseResource),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: typeof testCase.tx) => unknown) => callback(testCase.tx)),
};
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(JSON.parse(result.content)).toEqual({ error: testCase.expected });
}
});
it("returns stable validation errors for timeline mutation date ranges", async () => {
const baseProject = {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: null,
};
const baseResource = {
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,
},
};
const baseAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
...baseResource,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const cases = [
{
toolName: "update_timeline_allocation_inline",
payload: {
allocationId: "assignment_1",
startDate: "2026-03-20",
endDate: "2026-03-16",
},
db: {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(baseAssignment),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: baseResource.availability,
}),
},
},
},
{
toolName: "quick_assign_timeline_resource",
payload: {
resourceIdentifier: "resource_1",
projectIdentifier: "project_1",
startDate: "2026-03-20",
endDate: "2026-03-16",
hoursPerDay: 8,
},
db: {
project: {
findUnique: vi.fn().mockResolvedValue(baseProject),
},
resource: {
findUnique: vi.fn().mockResolvedValue(baseResource),
},
},
},
{
toolName: "batch_quick_assign_timeline_resources",
payload: {
assignments: [
{
resourceIdentifier: "resource_1",
projectIdentifier: "project_1",
startDate: "2026-03-20",
endDate: "2026-03-16",
hoursPerDay: 8,
},
],
},
db: {
project: {
findUnique: vi.fn().mockResolvedValue(baseProject),
},
resource: {
findUnique: vi.fn().mockResolvedValue(baseResource),
},
},
},
] as const;
for (const testCase of cases) {
const ctx = createToolContext(
testCase.db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
testCase.toolName,
JSON.stringify(testCase.payload),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "End date must be after start date",
});
}
});
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: {
@@ -1340,6 +1575,128 @@ describe("assistant advanced tools and scoping", () => {
}); });
}); });
it("returns stable allocation-not-found errors when timeline allocation persistence loses the row", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const missingDuringUpdate = {
code: "P2025",
message: "Record to update not found",
meta: { modelName: "Assignment" },
};
const updateInlineCtx = createToolContext(
{
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: existingAssignment.resource.availability,
}),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(async () => {
throw missingDuringUpdate;
}),
},
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const updateInlineResult = await executeTool(
"update_timeline_allocation_inline",
JSON.stringify({
allocationId: "assignment_1",
hoursPerDay: 6,
}),
updateInlineCtx,
);
expect(JSON.parse(updateInlineResult.content)).toEqual({
error: "Allocation not found with the given criteria.",
});
const batchShiftCtx = createToolContext(
{
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async () => {
throw missingDuringUpdate;
}),
},
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const batchShiftResult = await executeTool(
"batch_shift_timeline_allocations",
JSON.stringify({
allocationIds: ["assignment_1"],
daysDelta: 2,
mode: "move",
}),
batchShiftCtx,
);
expect(JSON.parse(batchShiftResult.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 () => { it("returns a stable allocation-not-found error for batch timeline shifts without matches", async () => {
const db = { const db = {
allocation: { allocation: {
@@ -2169,6 +2169,98 @@ describe("assistant import/export and dispo tools", () => {
}); });
}); });
it("returns stable assistant errors when broadcast fan-out loses sender or recipient rows inside the router transaction", async () => {
const senderMissingTx = {
notificationBroadcast: {
create: vi.fn().mockRejectedValue(
Object.assign(new Error("Foreign key constraint failed"), {
code: "P2003",
meta: { field_name: "NotificationBroadcast_senderId_fkey" },
}),
),
update: vi.fn(),
},
notification: {
create: vi.fn(),
},
};
const senderMissingCtx = createToolContext(
{
user: {
findMany: vi.fn().mockResolvedValue([{ id: "user_2" }]),
},
$transaction: vi.fn(async (callback: (db: typeof senderMissingTx) => Promise<unknown>) => callback(senderMissingTx)),
notificationBroadcast: {
create: vi.fn(),
update: vi.fn(),
},
notification: {
create: vi.fn(),
},
},
{ userRole: SystemRole.MANAGER },
);
const senderMissingResult = await executeTool(
"send_broadcast",
JSON.stringify({
title: "Office update",
targetType: "all",
}),
senderMissingCtx,
);
expect(JSON.parse(senderMissingResult.content)).toEqual({
error: "Sender user not found with the given criteria.",
});
const recipientMissingTx = {
notificationBroadcast: {
create: vi.fn().mockResolvedValue({
id: "broadcast_missing_recipient",
title: "Office update",
targetType: "all",
}),
update: vi.fn(),
},
notification: {
create: vi.fn().mockRejectedValue(
Object.assign(new Error("Foreign key constraint failed"), {
code: "P2003",
meta: { field_name: "Notification_userId_fkey" },
}),
),
},
};
const recipientMissingCtx = createToolContext(
{
user: {
findMany: vi.fn().mockResolvedValue([{ id: "user_2" }]),
},
$transaction: vi.fn(async (callback: (db: typeof recipientMissingTx) => Promise<unknown>) => callback(recipientMissingTx)),
notificationBroadcast: {
create: vi.fn(),
update: vi.fn(),
},
notification: {
create: vi.fn(),
},
},
{ userRole: SystemRole.MANAGER },
);
const recipientMissingResult = await executeTool(
"send_broadcast",
JSON.stringify({
title: "Office update",
targetType: "all",
}),
recipientMissingCtx,
);
expect(JSON.parse(recipientMissingResult.content)).toEqual({
error: "Broadcast recipient user not found with the given criteria.",
});
});
it("returns a stable assistant error when a broadcast target resolves to no recipients", async () => { it("returns a stable assistant error when a broadcast target resolves to no recipients", async () => {
const create = vi.fn().mockResolvedValue({ const create = vi.fn().mockResolvedValue({
id: "broadcast_empty", id: "broadcast_empty",