import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { App } from 'supertest/types'; import type { NotificationCategory } from '../generated/prisma'; import { berlinDateTime, berlinToday, dayToDate } from '../src/common/dates'; import { DomainEventsService } from '../src/domain-events/domain-events.service'; import { PermissionResolverService } from '../src/auth/permissions/permission-resolver.service'; import { DeliveryPreferences, isInQuietHours, } from '../src/notifications/delivery-time'; import { NotificationsService } from '../src/notifications/notifications.service'; import { PrismaService } from '../src/prisma/prisma.service'; import { TelegramSender } from '../src/telegram/telegram-sender'; import { drainDomainEvents } from './utils/domain-events'; import { createTelegramTestApp, FakeTelegramClient } from './utils/telegram'; const HOUR_MS = 3_600_000; const CLOCK_TOLERANCE_MS = 60_000; const ALL_CATEGORIES: NotificationCategory[] = [ 'TASK_DUE', 'DIGEST', 'SUGGESTION', 'SYSTEM', ]; const NEVER_ABORTED = new AbortController().signal; describe('Telegram delivery (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let telegram: FakeTelegramClient; let sender: TelegramSender; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const chatBase = 8_000_000_000n + BigInt(Math.round(Math.random() * 1e6)); let chatCounter = 0n; const roleIds: string[] = []; const userIds: string[] = []; const taskIds: string[] = []; interface TestUser { id: string; chatId: bigint; } async function createUser( label: string, permissions: string[], preference: { telegramCategories?: NotificationCategory[]; quietFrom?: string | null; quietUntil?: string | null; digestTime?: string; telegramDetail?: boolean; } = {}, linked = true, ): Promise { const role = await prisma.role.create({ data: { key: `TELEGRAM_${label.toUpperCase()}_${suffix}`, name: `Telegram ${label}`, permissions: { create: permissions.map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleIds.push(role.id); const user = await prisma.user.create({ data: { email: `telegram-e2e-${label}-${suffix}@example.com`, passwordHash: await bcrypt.hash('SuperSecret123!', 4), name: `Telegram ${label}`, role: { connect: { id: role.id } }, }, }); userIds.push(user.id); const chatId = chatBase + ++chatCounter; await prisma.notificationPreference.create({ data: { userId: user.id, telegramEnabled: true, telegramCategories: ALL_CATEGORIES, ...preference, }, }); if (linked) { await prisma.telegramLink.create({ data: { userId: user.id, chatId, telegramFirstName: label }, }); } return { id: user.id, chatId, roleId: role.id }; } /** A notification with its pending delivery, as the planner leaves them. */ async function queue( userId: string, options: { category?: NotificationCategory; message?: string; requiredPermissions?: string[]; createdAgoMs?: number; relatedType?: string; relatedId?: string; link?: string; } = {}, ): Promise<{ notificationId: string; deliveryId: string }> { const notification = await prisma.notification.create({ data: { userId, priority: 'INFO', category: options.category ?? 'SYSTEM', message: options.message ?? 'Hallo vom Test', requiredPermissions: options.requiredPermissions ?? [], relatedType: options.relatedType ?? null, relatedId: options.relatedId ?? null, link: options.link ?? '/dashboard', createdAt: new Date(Date.now() - (options.createdAgoMs ?? 0)), }, }); const delivery = await prisma.notificationDelivery.create({ data: { channel: 'TELEGRAM', userId, notificationId: notification.id, availableAt: new Date(Date.now() - 1_000), }, }); return { notificationId: notification.id, deliveryId: delivery.id }; } const drainFor = (...ids: string[]) => sender.drain(NEVER_ABORTED, 5, { userIds: ids }); const deliveryOf = (id: string) => prisma.notificationDelivery.findUniqueOrThrow({ where: { id } }); beforeAll(async () => { ({ app, telegram } = await createTelegramTestApp()); prisma = app.get(PrismaService); sender = app.get(TelegramSender); }); afterAll(async () => { await prisma.notificationDelivery.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); await app.close(); }); describe('planning', () => { it('a due task yields one delivery after the morning time and none inside quiet hours', async () => { const today = berlinToday(); const morning: DeliveryPreferences = { digestTime: '12:00', quietFrom: null, quietUntil: null, }; const night: DeliveryPreferences = { digestTime: '06:30', quietFrom: '00:00', quietUntil: '23:59', }; const early = await createUser('plan-early', ['tasks:read'], morning); const quiet = await createUser('plan-quiet', ['tasks:read'], night); const off = await createUser('plan-off', ['tasks:read'], { telegramCategories: ['DIGEST'], }); const task = await prisma.task.create({ data: { title: `Telegram e2e ${suffix}`, dueDate: dayToDate(today), }, }); taskIds.push(task.id); // Without an assignee every active user with tasks:complete gets it; // assign it to each user in turn to keep the test to its own users. const before = new Date(); for (const user of [early, quiet, off]) { await prisma.task.update({ where: { id: task.id }, data: { assignedUserId: user.id }, }); await prisma.$transaction((tx) => app.get(DomainEventsService).record(tx, { type: 'TaskDue', aggregateType: 'Task', aggregateId: task.id, payload: { dueOn: today }, dedupeKey: `TaskDue:${task.id}:${today}:${user.id}`, }), ); await drainDomainEvents(app, [task.id]); } const forUser = (userId: string) => prisma.notificationDelivery.findMany({ where: { userId }, include: { notification: true }, }); const [earlyRow, ...earlyRest] = await forUser(early.id); const [quietRow, ...quietRest] = await forUser(quiet.id); expect(earlyRow.notification?.category).toBe('TASK_DUE'); expect(earlyRest).toHaveLength(0); expect(earlyRow.channel).toBe('TELEGRAM'); expect(earlyRow.status).toBe('PENDING'); // Not before one minute after the morning time of that day; when that // time has passed already, right away. const morningAt = berlinDateTime(today, '12:01'); if (before < morningAt) { expect(earlyRow.availableAt).toEqual(morningAt); } else { expect(earlyRow.availableAt.getTime()).toBeGreaterThanOrEqual( before.getTime() - CLOCK_TOLERANCE_MS, ); } expect(isInQuietHours(earlyRow.availableAt, morning)).toBe(false); // Quiet hours all day: the delivery waits for their end. expect(quietRest).toHaveLength(0); expect(isInQuietHours(quietRow.availableAt, night)).toBe(false); expect(quietRow.availableAt.getTime()).toBeGreaterThanOrEqual( before.getTime() - CLOCK_TOLERANCE_MS, ); // The user who did not choose TASK_DUE has the notification, no delivery. expect(await forUser(off.id)).toHaveLength(0); expect( await prisma.notification.count({ where: { userId: off.id, category: 'TASK_DUE' }, }), ).toBe(1); }); it('a user without a link gets no delivery', async () => { const user = await createUser('plan-unlinked', [], {}, false); await app.get(NotificationsService).record({ userId: user.id, priority: 'INFO', category: 'SYSTEM', message: 'm', }); expect( await prisma.notificationDelivery.count({ where: { userId: user.id } }), ).toBe(0); }); it('queues the SYSTEM message of a user who chose that category, once, even if the key repeats', async () => { const user = await createUser('plan-system', [], { telegramCategories: ['SYSTEM'], quietFrom: null, quietUntil: null, }); const input = { userId: user.id, priority: 'INFO' as const, category: 'SYSTEM' as const, message: 'm', dedupeKey: `telegram-e2e-system:${suffix}`, }; await app.get(NotificationsService).record(input); await app.get(NotificationsService).record(input); expect( await prisma.notificationDelivery.count({ where: { userId: user.id } }), ).toBe(1); }); }); describe('sending', () => { it('two send loops in parallel deliver each message exactly once', async () => { const users = await Promise.all( ['par-a', 'par-b', 'par-c', 'par-d', 'par-e'].map((label) => createUser(label, []), ), ); const queued = await Promise.all( users.map((user) => queue(user.id, { message: `Nachricht ${user.id}` }), ), ); const second = new TelegramSender( prisma, telegram as never, app.get(PermissionResolverService), app.get(NotificationsService), ); const ids = users.map((user) => user.id); const claimed = await Promise.all([ sender.drain(NEVER_ABORTED, 5, { userIds: ids }), second.drain(NEVER_ABORTED, 5, { userIds: ids }), ]); expect(claimed[0] + claimed[1]).toBe(users.length); for (const user of users) { expect(telegram.sentTo(user.chatId)).toHaveLength(1); } for (const { deliveryId } of queued) { const row = await deliveryOf(deliveryId); expect(row.status).toBe('DELIVERED'); expect(row.deliveredAt).not.toBeNull(); } }); it('sends plain text with the app link', async () => { const user = await createUser('text', [], {}); const { deliveryId } = await queue(user.id, { message: 'Heute fällig: Futter', link: '/tasks', }); await drainFor(user.id); const [message] = telegram.sentTo(user.chatId); expect(message.text).toMatch( /^Heute fällig: Futter\n\nhttps?:\/\/.+\/tasks$/, ); expect((await deliveryOf(deliveryId)).status).toBe('DELIVERED'); }); it('a revoked permission skips the delivery at send time', async () => { const user = await createUser('revoked', ['tasks:read']); const { deliveryId } = await queue(user.id, { requiredPermissions: ['tasks:read'], }); // The resolver in this process has the right cached ... await app.get(PermissionResolverService).getPermissions(user.id); // ... and loses it without any invalidateUser call. await prisma.rolePermission.deleteMany({ where: { roleId: user.roleId }, }); await drainFor(user.id); expect(telegram.sentTo(user.chatId)).toHaveLength(0); const row = await deliveryOf(deliveryId); expect(row.status).toBe('SKIPPED'); expect(row.lastError).toBe('permission revoked'); }); it('skips what went stale: a DIGEST of yesterday, a SUGGESTION older than 24 h', async () => { const user = await createUser('stale', [], { telegramCategories: ['DIGEST', 'SUGGESTION', 'SYSTEM'], }); const digest = await queue(user.id, { category: 'DIGEST', createdAgoMs: 36 * HOUR_MS, }); const old = await queue(user.id, { category: 'SUGGESTION', createdAgoMs: 25 * HOUR_MS, }); await drainFor(user.id); const fresh = await queue(user.id, { category: 'SUGGESTION', createdAgoMs: 23 * HOUR_MS, }); await drainFor(user.id); expect((await deliveryOf(digest.deliveryId)).lastError).toBe('stale'); expect((await deliveryOf(old.deliveryId)).status).toBe('SKIPPED'); expect((await deliveryOf(fresh.deliveryId)).status).toBe('DELIVERED'); expect(telegram.sentTo(user.chatId)).toHaveLength(1); }); it('skips a due-task reminder once the task is done', async () => { const user = await createUser('taskdone', ['tasks:read'], { telegramCategories: ['TASK_DUE'], }); const task = await prisma.task.create({ data: { title: `Done ${suffix}`, status: 'DONE' }, }); taskIds.push(task.id); const { deliveryId } = await queue(user.id, { category: 'TASK_DUE', relatedType: 'Task', relatedId: task.id, requiredPermissions: ['tasks:read'], }); await drainFor(user.id); expect(telegram.sentTo(user.chatId)).toHaveLength(0); expect((await deliveryOf(deliveryId)).lastError).toBe( 'task already done', ); }); it('skips when Telegram was switched off, the link is gone or the category is off', async () => { const off = await createUser('sw-off', [], {}); await prisma.notificationPreference.update({ where: { userId: off.id }, data: { telegramEnabled: false }, }); const unlinked = await createUser('sw-unlinked', [], {}, false); const category = await createUser('sw-category', [], { telegramCategories: ['DIGEST'], }); const rows = await Promise.all([ queue(off.id), queue(unlinked.id), queue(category.id), ]); await drainFor(off.id, unlinked.id, category.id); const reasons = await Promise.all( rows.map(async (row) => (await deliveryOf(row.deliveryId)).lastError), ); expect(reasons).toEqual([ 'telegram disabled', 'not linked', 'category off', ]); }); it('403 removes the link, switches Telegram off and notifies the user in the app', async () => { const user = await createUser('blocked', []); const { deliveryId } = await queue(user.id); telegram.failWith( 403, 'Forbidden: bot was blocked by the user', user.chatId, ); await drainFor(user.id); expect( await prisma.telegramLink.count({ where: { userId: user.id } }), ).toBe(0); const preference = await prisma.notificationPreference.findUniqueOrThrow({ where: { userId: user.id }, }); expect(preference.telegramEnabled).toBe(false); const row = await deliveryOf(deliveryId); expect(row.status).toBe('SKIPPED'); expect(row.lastError).toBe('chat unavailable'); const notices = await prisma.notification.findMany({ where: { userId: user.id, category: 'SYSTEM', message: { contains: 'Zustellung beendet' }, }, }); expect(notices).toHaveLength(1); }); it('5xx keeps the delivery for a retry with the attempt counted', async () => { const user = await createUser('server-error', []); const { deliveryId } = await queue(user.id); telegram.failWith(502, 'Bad Gateway', user.chatId); await drainFor(user.id); const row = await deliveryOf(deliveryId); expect(row.status).toBe('PENDING'); expect(row.attempts).toBe(1); expect(row.availableAt.getTime()).toBeGreaterThan(Date.now()); expect(telegram.sentTo(user.chatId)).toHaveLength(0); }); it('keeps 1.1 s between two messages to the same chat, in order', async () => { const user = await createUser('spacing', []); const first = await queue(user.id, { message: 'erste' }); // Another createdAt, so the order is not a tie. await new Promise((resolve) => setTimeout(resolve, 20)); const second = await queue(user.id, { message: 'zweite' }); await drainFor(user.id); const sent = telegram .sentTo(user.chatId) .map((m) => m.text.split('\n')[0]); expect(sent).toEqual(['erste', 'zweite']); const [a, b] = await Promise.all([ deliveryOf(first.deliveryId), deliveryOf(second.deliveryId), ]); expect(a.status).toBe('DELIVERED'); expect(b.status).toBe('DELIVERED'); expect( (b.deliveredAt as Date).getTime() - (a.deliveredAt as Date).getTime(), ).toBeGreaterThanOrEqual(1_000); }); }); });