import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { App } from 'supertest/types'; import { addDays } from '../src/common/dates'; import { DomainEventsService } from '../src/domain-events/domain-events.service'; import type { AnyDomainEventEnvelope } from '../src/domain-events/domain-event-types'; import { PrismaService } from '../src/prisma/prisma.service'; import { DigestService } from '../src/reminders/digest.service'; import { TaskDueHandlers } from '../src/reminders/task-due.handlers'; import { createTestApp } from './utils/create-test-app'; import { drainDomainEvents } from './utils/domain-events'; const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); /** * Fixed day in 2001 on which the swarm season begins by calendar * (KIRSCHE 15 April), so an apiary's segment starts "today". Other files * share the database: assertions only look at the rows and lines of this * file's own users, apiary and tasks. */ const TODAY = '2001-04-15'; const YESTERDAY = '2001-04-14'; /** The digest window of a run at 04:30 (UTC, for the test) on `day`. */ const windowOf = (day: string) => ({ from: new Date(`${addDays(day, -1)}T02:30:00.000Z`), until: new Date(`${day}T02:30:00.000Z`), }); describe('Reminders (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let digests: DigestService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const roleIds: string[] = []; const userIds: string[] = []; const taskIds: string[] = []; const colonyIds: string[] = []; let apiaryId: string; let apiaryName: string; const users = {} as Record< 'full' | 'noInspection' | 'completer' | 'off' | 'nothing', string >; async function createUser( label: string, permissions: string[], ): Promise { const role = await prisma.role.create({ data: { key: `REMINDERS_${label.toUpperCase()}_${suffix}`, name: `Reminders ${label}`, permissions: { create: permissions.map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleIds.push(role.id); const user = await prisma.user.create({ data: { email: `reminders-e2e-${label}-${suffix}@example.com`, passwordHash: await bcrypt.hash('SuperSecret123!', 4), name: `Reminders ${label}`, role: { connect: { id: role.id } }, }, }); userIds.push(user.id); return user.id; } async function createTask( title: string, assignedUserId: string | null, dueDate: string, ): Promise { const task = await prisma.task.create({ data: { title, assignedUserId, dueDate: d(dueDate) }, }); taskIds.push(task.id); return task.id; } async function recordTaskDue(taskId: string, dueOn: string): Promise { await prisma.$transaction((tx) => app.get(DomainEventsService).record(tx, { type: 'TaskDue', aggregateType: 'Task', aggregateId: taskId, payload: { dueOn }, dedupeKey: `TaskDue:${taskId}:${dueOn}`, }), ); } const notificationsOf = (userId: string, relatedId: string) => prisma.notification.findMany({ where: { userId, relatedId } }); const digestOf = (userId: string) => prisma.notification.findMany({ where: { userId, dedupeKey: `digest:${TODAY}:${userId}` }, }); beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); digests = app.get(DigestService); users.full = await createUser('full', [ 'tasks:read', 'colonies:read', 'inspections:read', 'apiaries:read', ]); users.noInspection = await createUser('noinspection', [ 'tasks:read', 'apiaries:read', ]); users.completer = await createUser('completer', [ 'tasks:read', 'tasks:complete', ]); users.off = await createUser('off', ['tasks:read', 'apiaries:read']); users.nothing = await createUser('nothing', ['colonies:read']); await prisma.notificationPreference.create({ data: { userId: users.off, taskDueEnabled: false, digestEnabled: false }, }); apiaryName = `Reminders e2e ${suffix}`; apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id; for (const code of ['A', 'B']) { const colony = await prisma.colony.create({ data: { code: `REM-E2E-${suffix}-${code}`, apiaryId, colonyType: 'PRODUCTION_COLONY', createdAt: d('2001-03-01'), }, }); colonyIds.push(colony.id); await prisma.inspection.create({ data: { colonyId: colony.id, userId: users.full, date: d('2001-03-10'), }, }); } // Two segment changes of the apiary: one before yesterday's digest // (reported then), one after it (reported today). Status DONE, so no // dispatcher touches them. const seasonEvent = ( previousSegment: string, segment: string, occurredAt: string, ) => prisma.domainEvent.create({ data: { type: 'SeasonPhaseChanged', aggregateType: 'Apiary', aggregateId: apiaryId, payload: { apiaryId, previousSegment, segment }, correlationId: `reminders-e2e-${suffix}`, occurredAt: new Date(occurredAt), status: 'DONE', }, }); await seasonEvent('FRUEHJAHR', 'SCHWARMZEIT', '2001-04-13T12:00:00.000Z'); await seasonEvent('SCHWARMZEIT', 'SOMMER', '2001-04-14T12:00:00.000Z'); }); afterAll(async () => { await prisma.notification.deleteMany({ where: { OR: [{ userId: { in: userIds } }, { relatedId: { in: taskIds } }], }, }); await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: [...taskIds, apiaryId] } }, }); await prisma.inspection.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.apiary.delete({ where: { id: apiaryId } }); await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); await app.close(); }); it('TaskDue after draining yields exactly one notification per recipient, also after redelivery', async () => { const taskId = await createTask('Reminders e2e Waben', users.full, TODAY); await recordTaskDue(taskId, TODAY); await drainDomainEvents(app, [taskId]); const rows = await notificationsOf(users.full, taskId); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ priority: 'HINWEIS', category: 'TASK_DUE', message: 'Fällig seit 15.04.: Reminders e2e Waben', link: '/tasks', relatedType: 'Task', dedupeKey: `task-due:${taskId}:${TODAY}:${users.full}`, requiredPermissions: ['tasks:read'], }); // A redelivery runs the handler a second time. const envelope: AnyDomainEventEnvelope<'TaskDue'> = { id: 'redelivery', type: 'TaskDue', aggregateType: 'Task', aggregateId: taskId, payload: { dueOn: TODAY }, actorUserId: null, correlationId: 'redelivery', occurredAt: new Date(), attempts: 1, }; await prisma.$transaction((tx) => app.get(TaskDueHandlers).taskDue(envelope, tx), ); expect(await notificationsOf(users.full, taskId)).toHaveLength(1); for (const other of [users.noInspection, users.completer, users.nothing]) { expect(await notificationsOf(other, taskId)).toHaveLength(0); } }); it('a task without an assignee reaches holders of tasks:complete and not users who switched it off', async () => { const taskId = await createTask('Reminders e2e offen', null, TODAY); await recordTaskDue(taskId, TODAY); await drainDomainEvents(app, [taskId]); expect(await notificationsOf(users.completer, taskId)).toHaveLength(1); for (const other of [users.full, users.noInspection, users.off]) { expect(await notificationsOf(other, taskId)).toHaveLength(0); } }); it('writes nothing for a task that was completed before delivery', async () => { const taskId = await createTask( 'Reminders e2e erledigt', users.full, TODAY, ); await prisma.task.update({ where: { id: taskId }, data: { status: 'DONE', completedAt: new Date() }, }); await recordTaskDue(taskId, TODAY); await drainDomainEvents(app, [taskId]); expect(await notificationsOf(users.full, taskId)).toHaveLength(0); }); it('running the digest step twice yields one digest per user', async () => { // Plus the open task of the first test: three due, one overdue. await createTask('Reminders e2e heute', users.full, TODAY); await createTask('Reminders e2e ueberfaellig', users.full, '2001-04-10'); const own = [ users.full, users.noInspection, users.completer, users.off, users.nothing, ]; const first = await digests.runFor(TODAY, own, windowOf(TODAY)); const second = await digests.runFor(TODAY, own, windowOf(TODAY)); expect(first).toBeGreaterThanOrEqual(2); expect(second).toBe(0); for (const userId of own) { expect((await digestOf(userId)).length).toBeLessThanOrEqual(1); } const [full] = await digestOf(users.full); expect(full).toMatchObject({ priority: 'INFO', category: 'DIGEST', link: '/dashboard', }); const lines = full.message.split('\n'); expect(lines[0]).toBe('Guten Morgen. Heute für dich:'); expect(lines).toContain('3 Aufgaben fällig, 1 davon überfällig.'); expect(lines).toContain( `Durchsicht fällig: 2 Völker am Stand ${apiaryName}.`, ); expect(lines).toContain( `Stand ${apiaryName}: Sommer beginnt. Durchsicht alle 14 Tage.`, ); // The change before yesterday's digest is not repeated. expect(full.message).not.toContain('Schwarmzeit beginnt'); expect(full.requiredPermissions).toEqual( expect.arrayContaining([ 'tasks:read', 'colonies:read', 'inspections:read', 'apiaries:read', ]), ); }); it('the digest of the day before reports the change of its own window', async () => { await digests.runFor(YESTERDAY, [users.full], windowOf(YESTERDAY)); const [yesterday] = await prisma.notification.findMany({ where: { userId: users.full, dedupeKey: `digest:${YESTERDAY}:${users.full}`, }, }); expect(yesterday.message).toContain( `Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`, ); expect(yesterday.message).not.toContain('Sommer beginnt'); }); it('the digest of a user without varroa or inspection rights has no such line', async () => { await digests.runFor(TODAY, [users.noInspection], windowOf(TODAY)); const [digest] = await digestOf(users.noInspection); expect(digest.message).toContain(`Stand ${apiaryName}: Sommer`); expect(digest.message).not.toContain('Durchsicht fällig'); expect(digest.requiredPermissions).not.toContain('inspections:read'); expect(digest.requiredPermissions).not.toContain('colonies:read'); }); it('builds no digest for a user who can see nothing in it, and none for a user who switched it off', async () => { await digests.runFor(TODAY, [users.nothing, users.off], windowOf(TODAY)); expect(await digestOf(users.nothing)).toHaveLength(0); expect(await digestOf(users.off)).toHaveLength(0); }); });