Files
ApiCore/ApiCore-Backend/src/reminders/task-due.handlers.spec.ts
T
Nick SzaunigandClaude Opus 5.5 32c61c8e14 feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI
- In-app reminders: task due (skips rescheduled tasks, catch-up wording)
  and a per-user morning digest step in the daily job
- Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard,
  inspection-due card, season chip, apiary overview fields, cleanup
  sheet for ended colonies
- Frontend: plausibility warnings that stay until dismissed, withdrawal
  spans in the calendar, notification bell with categories and links,
  notification settings in the profile
- ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler
  table and daily job step order

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 17:05:32 +02:00

217 lines
6.4 KiB
TypeScript

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<string, unknown>, unknown];
describe('TaskDueHandlers', () => {
let task: Record<string, unknown> | 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:<taskId>:<day> as prefix, so rows end in :<userId>', 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();
});
});