From 732538857b72c3555398e3ec0d0a5ca214bec824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 12:23:46 +0200 Subject: [PATCH] test(api): cover remaining timeline and broadcast fallback races --- .../assistant-tools-advanced.test.ts | 357 ++++++++++++++++++ .../assistant-tools-import-export.test.ts | 92 +++++ 2 files changed, 449 insertions(+) diff --git a/packages/api/src/__tests__/assistant-tools-advanced.test.ts b/packages/api/src/__tests__/assistant-tools-advanced.test.ts index 9f7151c..69f15a5 100644 --- a/packages/api/src/__tests__/assistant-tools-advanced.test.ts +++ b/packages/api/src/__tests__/assistant-tools-advanced.test.ts @@ -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 () => { const db = { 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 () => { const db = { allocation: { diff --git a/packages/api/src/__tests__/assistant-tools-import-export.test.ts b/packages/api/src/__tests__/assistant-tools-import-export.test.ts index 0118d85..2110cbc 100644 --- a/packages/api/src/__tests__/assistant-tools-import-export.test.ts +++ b/packages/api/src/__tests__/assistant-tools-import-export.test.ts @@ -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) => 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) => 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 () => { const create = vi.fn().mockResolvedValue({ id: "broadcast_empty",