import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { NotificationsService } from '../src/notifications/notifications.service'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; interface NotificationBody { id: string; priority: string; category: string; read: boolean; } interface SettingsBody { taskDueEnabled: boolean; digestEnabled: boolean; digestTime: string; quietFrom: string | null; quietUntil: string | null; telegramCategories: string[]; telegramDetail: boolean; } describe('Notifications (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let notifications: NotificationsService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; const emails = { beekeeper: `notifications-e2e-beekeeper-${suffix}@example.com`, staff: `notifications-e2e-staff-${suffix}@example.com`, bare: `notifications-e2e-bare-${suffix}@example.com`, admin: `notifications-e2e-admin-${suffix}@example.com`, }; const roleKey = `NOTIF_BARE_${suffix}`; const ids = {} as Record; const tokens = {} as Record; const createdNotificationIds: string[] = []; let apiKeyId: string | undefined; const server = () => app.getHttpServer(); const as = (who: keyof typeof emails) => ({ Authorization: `Bearer ${tokens[who]}`, }); async function seedNotification( userId: string, overrides: Record = {}, ) { const row = await prisma.notification.create({ data: { userId, priority: 'KRITISCH', category: 'SYSTEM', message: `Kritischer Testalarm ${suffix}`, ...overrides, }, }); createdNotificationIds.push(row.id); return row; } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); notifications = app.get(NotificationsService); await prisma.role.create({ data: { key: roleKey, name: 'Notifications bare role' }, }); const passwordHash = await bcrypt.hash(password, 12); const roleOf: Record = { beekeeper: 'BEEKEEPER', staff: 'STAFF', bare: roleKey, admin: 'ADMIN', }; for (const who of Object.keys(emails) as (keyof typeof emails)[]) { const user = await prisma.user.create({ data: { email: emails[who], passwordHash, name: who, role: { connect: { key: roleOf[who] } }, }, }); ids[who] = user.id; tokens[who] = (await login(app, emails[who], password)).accessToken; } }); afterAll(async () => { const userIds = Object.values(ids); if (apiKeyId) { await prisma.apiKey.deleteMany({ where: { id: apiKeyId } }); } await prisma.notification.deleteMany({ where: { OR: [ { id: { in: createdNotificationIds } }, { userId: { in: userIds } }, ], }, }); await prisma.notificationPreference.deleteMany({ where: { userId: { in: userIds } }, }); 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: { key: roleKey } }); await app.close(); }); it('lists notifications for the authenticated user', async () => { const own = await seedNotification(ids.beekeeper); const response = await request(server()) .get('/api/v1/notifications') .set(as('beekeeper')) .expect(200); const list = response.body as NotificationBody[]; expect(list.some((n) => n.id === own.id && n.priority === 'KRITISCH')).toBe( true, ); expect(list[0]).not.toHaveProperty('requiredPermissions'); expect(list[0]).not.toHaveProperty('dedupeKey'); }); it('hides a row whose permissions the user does not hold (any more)', async () => { const open = await seedNotification(ids.bare); const hidden = await seedNotification(ids.bare, { requiredPermissions: ['colonies:read'], }); const response = await request(server()) .get('/api/v1/notifications?unreadOnly=true') .set(as('bare')) .expect(200); const listed = (response.body as NotificationBody[]).map((n) => n.id); expect(listed).toContain(open.id); expect(listed).not.toContain(hidden.id); }); it('a row the user may not see cannot be read, marked or counted', async () => { const hidden = await seedNotification(ids.bare, { requiredPermissions: ['colonies:read'], }); const open = await seedNotification(ids.bare); await request(server()) .post(`/api/v1/notifications/${hidden.id}/read`) .set(as('bare')) .expect(404); const response = await request(server()) .post('/api/v1/notifications/read-all') .set(as('bare')) .expect(201); expect( (response.body as { updated: number }).updated, ).toBeGreaterThanOrEqual(1); expect( (await prisma.notification.findUniqueOrThrow({ where: { id: open.id } })) .read, ).toBe(true); expect( ( await prisma.notification.findUniqueOrThrow({ where: { id: hidden.id }, }) ).read, ).toBe(false); }); it('reading is per user: marking one copy read leaves the other unread', async () => { const [first, second] = await notifications.recordForUsers( [ids.beekeeper, ids.staff], { priority: 'INFO', category: 'SYSTEM', message: `Rundmeldung ${suffix}`, dedupeKeyPrefix: `e2e-read:${suffix}`, }, ); createdNotificationIds.push(first.id, second.id); const mine = [first, second].find((n) => n.userId === ids.beekeeper)!; const theirs = [first, second].find((n) => n.userId === ids.staff)!; const response = await request(server()) .post(`/api/v1/notifications/${mine.id}/read`) .set(as('beekeeper')) .expect(201); expect((response.body as NotificationBody).read).toBe(true); expect( ( await prisma.notification.findUniqueOrThrow({ where: { id: theirs.id }, }) ).read, ).toBe(false); }); it('a fan-out for colonies:read never reaches a user without it', async () => { const rows = await notifications.recordForPermission('colonies:read', { priority: 'INFO', category: 'SUGGESTION', message: `Nur mit Volksrecht ${suffix}`, dedupeKeyPrefix: `e2e-fanout:${suffix}`, }); createdNotificationIds.push(...rows.map((row) => row.id)); const recipients = rows.map((row) => row.userId); expect(recipients).toContain(ids.beekeeper); expect(recipients).not.toContain(ids.bare); expect( rows.every((row) => row.requiredPermissions.includes('colonies:read')), ).toBe(true); const none = await notifications.recordForUsers([ids.bare], { priority: 'INFO', category: 'SUGGESTION', message: 'x', requiredPermissions: ['colonies:read'], }); expect(none).toEqual([]); }); it('a repeated dedupe key writes nothing the second time', async () => { const input = { priority: 'INFO' as const, category: 'SYSTEM' as const, message: `Einmalig ${suffix}`, dedupeKeyPrefix: `e2e-dedupe:${suffix}`, }; const first = await notifications.recordForUsers([ids.beekeeper], input); createdNotificationIds.push(...first.map((row) => row.id)); const second = await notifications.recordForUsers([ids.beekeeper], input); expect(first).toHaveLength(1); expect(second).toEqual([]); }); it('read-all marks only own notifications', async () => { const own = await seedNotification(ids.staff); const foreign = await seedNotification(ids.beekeeper); const response = await request(server()) .post('/api/v1/notifications/read-all') .set(as('staff')) .expect(201); expect( (response.body as { updated: number }).updated, ).toBeGreaterThanOrEqual(1); expect( (await prisma.notification.findUniqueOrThrow({ where: { id: own.id } })) .read, ).toBe(true); expect( ( await prisma.notification.findUniqueOrThrow({ where: { id: foreign.id }, }) ).read, ).toBe(false); }); it('reading a foreign notification answers 404', async () => { const foreign = await seedNotification(ids.beekeeper); await request(server()) .post(`/api/v1/notifications/${foreign.id}/read`) .set(as('staff')) .expect(404); await request(server()) .post('/api/v1/notifications/does-not-exist/read') .set(as('staff')) .expect(404); expect( ( await prisma.notification.findUniqueOrThrow({ where: { id: foreign.id }, }) ).read, ).toBe(false); }); describe('notification settings', () => { it('an API key gets 403 on /profile/notification-settings', async () => { const created = await request(server()) .post('/api/v1/api-keys') .set(as('admin')) .send({ name: `notif-e2e ${suffix}`, scopes: ['shop-sync:read'] }) .expect(201); const body = created.body as { apiKey: { id: string }; token: string }; apiKeyId = body.apiKey.id; await request(server()) .get('/api/v1/profile/notification-settings') .set('Authorization', `Bearer ${body.token}`) .expect(403); }); it('returns the defaults on first read', async () => { const response = await request(server()) .get('/api/v1/profile/notification-settings') .set(as('bare')) .expect(200); expect(response.body).toEqual({ taskDueEnabled: true, digestEnabled: true, digestTime: '06:30', quietFrom: '21:00', quietUntil: '06:30', telegramCategories: ['TASK_DUE', 'DIGEST'], telegramDetail: true, }); }); it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => { const response = await request(server()) .patch('/api/v1/profile/notification-settings') .set(as('beekeeper')) .send({ digestTime: '07:15', quietFrom: '22:00', quietUntil: '05:30', telegramCategories: ['SYSTEM', 'WITHDRAWAL'], telegramDetail: false, }) .expect(200); const settings = response.body as SettingsBody; expect(settings).toMatchObject({ digestTime: '07:15', quietFrom: '22:00', quietUntil: '05:30', telegramCategories: ['SYSTEM', 'WITHDRAWAL'], telegramDetail: false, taskDueEnabled: true, }); const stored = await prisma.notificationPreference.findUniqueOrThrow({ where: { userId: ids.beekeeper }, }); expect(stored.telegramEnabled).toBe(false); expect(stored.digestTime).toBe('07:15'); }); it('rejects a digest time outside 05:00 to 12:00 and a lone quiet-hour value', async () => { const early = await request(server()) .patch('/api/v1/profile/notification-settings') .set(as('staff')) .send({ digestTime: '04:30' }) .expect(400); expect(JSON.stringify(early.body)).toContain( 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.', ); const lone = await request(server()) .patch('/api/v1/profile/notification-settings') .set(as('staff')) .send({ quietFrom: null }) .expect(400); expect(JSON.stringify(lone.body)).toContain( 'Bitte Beginn und Ende der Ruhezeit zusammen leeren.', ); await request(server()) .patch('/api/v1/profile/notification-settings') .set(as('staff')) .send({ quietFrom: null, quietUntil: null }) .expect(200); await request(server()) .patch('/api/v1/profile/notification-settings') .set(as('staff')) .send({ digestEnabled: null }) .expect(400); }); it('does not accept telegramEnabled', async () => { await request(server()) .patch('/api/v1/profile/notification-settings') .set(as('staff')) .send({ telegramEnabled: true }) .expect(400); }); }); });