From 5fae007a3bb7c47a6e08585964160fb9d7a87017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 00:06:51 +0200 Subject: [PATCH] test(api): cover assistant reminder tools --- ...reminder-create-persistence-errors.test.ts | 110 +++++++++++++ ...-reminder-create-validation-errors.test.ts | 114 +++++++++++++ .../assistant-tools-reminder-list.test.ts | 53 ++++++ ...nt-tools-reminder-mutations-errors.test.ts | 53 ++++++ ...t-tools-reminder-mutations-success.test.ts | 155 ++++++++++++++++++ 5 files changed, 485 insertions(+) create mode 100644 packages/api/src/__tests__/assistant-tools-reminder-create-persistence-errors.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-reminder-create-validation-errors.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-reminder-list.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-reminder-mutations-errors.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-reminder-mutations-success.test.ts diff --git a/packages/api/src/__tests__/assistant-tools-reminder-create-persistence-errors.test.ts b/packages/api/src/__tests__/assistant-tools-reminder-create-persistence-errors.test.ts new file mode 100644 index 0000000..d76c905 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-reminder-create-persistence-errors.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + 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-notification-test-helpers.js"; + +describe("assistant reminder creation tools - persistence errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when reminder creation loses its authenticated user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Submit report", + remindAt: "2026-04-01T09:00:00.000Z", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Authenticated user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when reminder creation is rejected by the backing router", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + create: vi.fn(), + }, + }, + SystemRole.ADMIN, + ); + + ctx.db.notification.create.mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Reminder payload is invalid", + }), + ); + + const result = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Submit report", + remindAt: "2026-04-01T09:00:00.000Z", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Reminder input is invalid.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-reminder-create-validation-errors.test.ts b/packages/api/src/__tests__/assistant-tools-reminder-create-validation-errors.test.ts new file mode 100644 index 0000000..d8cefed --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-reminder-create-validation-errors.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + 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-notification-test-helpers.js"; + +describe("assistant reminder creation tools - validation errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when reminder creation receives an invalid datetime", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Submit report", + remindAt: "not-a-datetime", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid remindAt: not-a-datetime", + }); + }); + + it("returns stable assistant errors for reminder validation edge cases", async () => { + const cases = [ + { + payload: { + title: " ", + remindAt: "2026-04-01T09:00:00.000Z", + }, + expected: "Reminder title is required.", + }, + { + payload: { + title: "x".repeat(201), + remindAt: "2026-04-01T09:00:00.000Z", + }, + expected: "Reminder title must be at most 200 characters.", + }, + { + payload: { + title: "Submit report", + body: "x".repeat(2001), + remindAt: "2026-04-01T09:00:00.000Z", + }, + expected: "Reminder body must be at most 2000 characters.", + }, + { + payload: { + title: "Submit report", + remindAt: "2026-04-01T09:00:00.000Z", + recurrence: "yearly", + }, + expected: "Invalid recurrence: yearly. Valid values: daily, weekly, monthly.", + }, + ] as const; + + for (const testCase of cases) { + const ctx = createToolContext({}, SystemRole.ADMIN); + const result = await executeTool( + "create_reminder", + JSON.stringify(testCase.payload), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ error: testCase.expected }); + } + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-reminder-list.test.ts b/packages/api/src/__tests__/assistant-tools-reminder-list.test.ts new file mode 100644 index 0000000..32bf83e --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-reminder-list.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext, withUserLookup } from "./assistant-tools-notification-test-helpers.js"; + +describe("assistant reminder list tools", () => { + it("lists reminders through the real router path for the current user", async () => { + const db = withUserLookup({ + notification: { + findMany: vi.fn().mockResolvedValue([ + { + id: "rem_1", + userId: "user_1", + category: "REMINDER", + title: "Check holiday setup", + nextRemindAt: new Date("2026-05-01T07:00:00.000Z"), + }, + ]), + }, + }); + const ctx = createToolContext(db, SystemRole.USER); + + const result = await executeTool( + "list_reminders", + JSON.stringify({ limit: 10 }), + ctx, + ); + + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { userId: "user_1", category: "REMINDER" }, + orderBy: { nextRemindAt: "asc" }, + take: 10, + }); + expect(JSON.parse(result.content)).toEqual([ + expect.objectContaining({ + id: "rem_1", + title: "Check holiday setup", + category: "REMINDER", + }), + ]); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-reminder-mutations-errors.test.ts b/packages/api/src/__tests__/assistant-tools-reminder-mutations-errors.test.ts new file mode 100644 index 0000000..a34d420 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-reminder-mutations-errors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext, withUserLookup } from "./assistant-tools-notification-test-helpers.js"; + +describe("assistant reminder mutation tools - errors", () => { + it("returns a stable assistant error when updating a missing reminder", async () => { + const ctx = createToolContext(withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }), SystemRole.ADMIN); + + const result = await executeTool( + "update_reminder", + JSON.stringify({ id: "rem_missing", title: "Updated" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Reminder not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when deleting a missing reminder", async () => { + const ctx = createToolContext(withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }), SystemRole.ADMIN); + + const result = await executeTool( + "delete_reminder", + JSON.stringify({ id: "rem_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Reminder not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-reminder-mutations-success.test.ts b/packages/api/src/__tests__/assistant-tools-reminder-mutations-success.test.ts new file mode 100644 index 0000000..a3298be --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-reminder-mutations-success.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext, withUserLookup } from "./assistant-tools-notification-test-helpers.js"; + +describe("assistant reminder mutation tools - success", () => { + it("parses reminder datetimes and forwards optional reminder fields", async () => { + const db = withUserLookup({ + notification: { + create: vi.fn().mockResolvedValue({ + id: "rem_1", + title: "Check holiday setup", + category: "REMINDER", + }), + }, + }); + const ctx = createToolContext(db, SystemRole.USER); + + const result = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Check holiday setup", + body: "Compare Bavaria and Hamburg", + remindAt: "2026-05-02T10:15:00.000Z", + recurrence: "monthly", + entityId: "calendar_1", + entityType: "HOLIDAY_CALENDAR", + link: "/holidays", + }), + ctx, + ); + + expect(db.notification.create).toHaveBeenCalledWith({ + data: { + userId: "user_1", + type: "REMINDER", + category: "REMINDER", + title: "Check holiday setup", + body: "Compare Bavaria and Hamburg", + remindAt: new Date("2026-05-02T10:15:00.000Z"), + nextRemindAt: new Date("2026-05-02T10:15:00.000Z"), + recurrence: "monthly", + entityId: "calendar_1", + entityType: "HOLIDAY_CALENDAR", + link: "/holidays", + channel: "in_app", + }, + }); + expect(result.action).toEqual({ type: "invalidate", scope: ["notification"] }); + expect(result.data).toEqual( + expect.objectContaining({ + success: true, + reminderId: "rem_1", + message: 'Reminder "Check holiday setup" created.', + }), + ); + }); + + it("updates reminders through the real router path and invalidates notification queries", async () => { + const remindAt = new Date("2026-05-01T07:00:00.000Z"); + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "rem_1", + userId: "user_1", + category: "REMINDER", + }), + update: vi.fn().mockResolvedValue({ + id: "rem_1", + title: "Updated reminder", + remindAt, + nextRemindAt: remindAt, + recurrence: null, + }), + }, + }); + const ctx = createToolContext(db, SystemRole.USER); + + const result = await executeTool( + "update_reminder", + JSON.stringify({ + id: "rem_1", + title: "Updated reminder", + remindAt: "2026-05-01T07:00:00.000Z", + recurrence: null, + }), + ctx, + ); + + expect(db.notification.findFirst).toHaveBeenCalledWith({ + where: { id: "rem_1", userId: "user_1", category: "REMINDER" }, + }); + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "rem_1" }, + data: { + title: "Updated reminder", + remindAt, + nextRemindAt: remindAt, + recurrence: null, + }, + }); + expect(result.action).toEqual({ type: "invalidate", scope: ["notification"] }); + expect(result.data).toEqual( + expect.objectContaining({ + success: true, + reminderId: "rem_1", + message: "Updated reminder rem_1.", + reminder: expect.objectContaining({ + id: "rem_1", + title: "Updated reminder", + recurrence: null, + }), + }), + ); + }); + + it("deletes reminders through the router and returns notification invalidation metadata", async () => { + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "rem_1", + userId: "user_1", + category: "REMINDER", + }), + delete: vi.fn().mockResolvedValue({ id: "rem_1" }), + }, + }); + const ctx = createToolContext(db, SystemRole.USER); + + const result = await executeTool( + "delete_reminder", + JSON.stringify({ id: "rem_1" }), + ctx, + ); + + expect(db.notification.delete).toHaveBeenCalledWith({ where: { id: "rem_1" } }); + expect(result.action).toEqual({ type: "invalidate", scope: ["notification"] }); + expect(result.data).toEqual({ + success: true, + id: "rem_1", + message: "Deleted reminder rem_1.", + }); + }); +});