import type { Prisma } from '../../generated/prisma'; import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; import type { NotificationsService } from '../notifications/notifications.service'; import { TaskDueHandlers } from './task-due.handlers'; function taskDue( taskId = 'task-1', dueOn = '2026-06-10', ): AnyDomainEventEnvelope<'TaskDue'> { return { id: 'evt-1', type: 'TaskDue', aggregateType: 'Task', aggregateId: taskId, payload: { dueOn }, actorUserId: null, correlationId: 'cor-1', occurredAt: new Date(), attempts: 0, }; } type RecordCall = [string[], Record, unknown]; describe('TaskDueHandlers', () => { let task: Record | null; let completers: { id: string }[]; let disabled: { userId: string }[]; let taskFindUnique: jest.Mock; let userFindMany: jest.Mock; let preferenceFindMany: jest.Mock; let recordForUsers: jest.Mock; let handlers: TaskDueHandlers; const tx = {} as Prisma.TransactionClient; beforeEach(() => { jest.useFakeTimers({ now: new Date('2026-06-10T08:00:00.000Z') }); task = { id: 'task-1', title: 'Waben pruefen', status: 'OPEN', assignedUserId: 'u-assignee', dueDate: new Date('2026-06-10T00:00:00.000Z'), }; completers = [{ id: 'u-a' }, { id: 'u-b' }]; disabled = []; taskFindUnique = jest.fn(() => Promise.resolve(task)); userFindMany = jest.fn(() => Promise.resolve(completers)); preferenceFindMany = jest.fn(() => Promise.resolve(disabled)); recordForUsers = jest.fn().mockResolvedValue([]); const client = { task: { findUnique: taskFindUnique }, user: { findMany: userFindMany }, notificationPreference: { findMany: preferenceFindMany }, } as unknown as Prisma.TransactionClient; handlers = new TaskDueHandlers({ recordForUsers, } as unknown as NotificationsService); Object.assign(tx, client); }); const firstCall = (): RecordCall => recordForUsers.mock.calls[0] as RecordCall; afterEach(() => jest.useRealTimers()); it('is registered as notifications.task-due for TaskDue', () => { const entries = domainEventHandlerMetadata( // eslint-disable-next-line @typescript-eslint/unbound-method TaskDueHandlers.prototype.taskDue, ); expect(entries).toEqual([ { types: ['TaskDue'], handlerId: 'notifications.task-due' }, ]); }); it('notifies the assignee only', async () => { await handlers.taskDue(taskDue(), tx); expect(userFindMany).not.toHaveBeenCalled(); expect(recordForUsers).toHaveBeenCalledTimes(1); expect(firstCall()[0]).toEqual(['u-assignee']); }); it('without an assignee notifies holders of tasks:complete', async () => { task = { ...task, assignedUserId: null }; await handlers.taskDue(taskDue(), tx); expect(userFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: { isActive: true, role: { status: 'ACTIVE', permissions: { some: { permission: { key: 'tasks:complete' } } }, }, }, }), ); expect(firstCall()[0]).toEqual(['u-a', 'u-b']); }); it('writes nothing for a user with taskDueEnabled false', async () => { task = { ...task, assignedUserId: null }; disabled = [{ userId: 'u-a' }]; await handlers.taskDue(taskDue(), tx); expect(preferenceFindMany).toHaveBeenCalledWith({ where: { userId: { in: ['u-a', 'u-b'] }, taskDueEnabled: false }, select: { userId: true }, }); expect(firstCall()[0]).toEqual(['u-b']); }); it('writes nothing when the only recipient switched it off', async () => { disabled = [{ userId: 'u-assignee' }]; await handlers.taskDue(taskDue(), tx); expect(recordForUsers).not.toHaveBeenCalled(); }); it('uses the key task-due:: as prefix, so rows end in :', async () => { task = { ...task, dueDate: new Date('2026-06-09T00:00:00.000Z') }; await handlers.taskDue(taskDue('task-1', '2026-06-09'), tx); expect(firstCall()[1]).toEqual({ priority: 'HINWEIS', category: 'TASK_DUE', message: 'Fällig seit 09.06.: Waben pruefen', relatedType: 'Task', relatedId: 'task-1', link: '/tasks', requiredPermissions: ['tasks:read'], dedupeKeyPrefix: 'task-due:task-1:2026-06-09', }); expect(firstCall()[2]).toBe(tx); }); it.each(['DONE', 'CANCELLED'])('skips a %s task', async (status) => { task = { ...task, status }; await handlers.taskDue(taskDue(), tx); expect(recordForUsers).not.toHaveBeenCalled(); }); it('says "Heute fällig" for an event of today', async () => { await handlers.taskDue(taskDue(), tx); expect(firstCall()[1].message).toBe('Heute fällig: Waben pruefen'); }); it('says "Fällig seit {TT.MM.}" for a catch-up event', async () => { task = { ...task, dueDate: new Date('2026-06-07T00:00:00.000Z') }; await handlers.taskDue(taskDue('task-1', '2026-06-07'), tx); expect(firstCall()[1].message).toBe('Fällig seit 07.06.: Waben pruefen'); }); it('skips a task whose due day was moved after the event', async () => { task = { ...task, dueDate: new Date('2026-06-20T00:00:00.000Z') }; await handlers.taskDue(taskDue('task-1', '2026-06-10'), tx); expect(recordForUsers).not.toHaveBeenCalled(); }); it('skips a task whose due date was removed', async () => { task = { ...task, dueDate: null }; await handlers.taskDue(taskDue(), tx); expect(recordForUsers).not.toHaveBeenCalled(); }); it('accepts an IN_PROGRESS task', async () => { task = { ...task, status: 'IN_PROGRESS' }; await handlers.taskDue(taskDue(), tx); expect(recordForUsers).toHaveBeenCalledTimes(1); }); it('skips a task that no longer exists', async () => { task = null; await handlers.taskDue(taskDue(), tx); expect(recordForUsers).not.toHaveBeenCalled(); }); it('cuts the title to 200 characters', async () => { task = { ...task, title: 'x'.repeat(300) }; await handlers.taskDue(taskDue(), tx); const input = firstCall()[1]; expect(input.message).toBe(`Heute fällig: ${'x'.repeat(200)}`); }); it('writes nothing when no user holds tasks:complete', async () => { task = { ...task, assignedUserId: null }; completers = []; await handlers.taskDue(taskDue(), tx); expect(recordForUsers).not.toHaveBeenCalled(); }); });