import { INestApplication } from '@nestjs/common'; import { PrismaClient, User } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { DigestService } from './digest.service'; import { NotificationsService } from './notifications.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; const HOUR = 60 * 60 * 1000; /** * E-mail digests (issue #95): grouping into exactly one mail, the cadence * windows with time travel, the session-free unsubscribe link, and the * send-time permission re-check. */ describe.skipIf(!hasTestDb)('notification digests (e2e, issue #95)', () => { let app: INestApplication; let prisma: PrismaClient; let digest: DigestService; const suffix = uniqueSuffix(); const password = 'gebuendelt statt einzeln 1'; const users: Record = {}; const cookies: Record = {}; let pondId: string; let pageId: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { const service = app.get(UsersService); const username = `di-${handle}-${suffix}`; const user = await service.createUser({ username, email: `${username}@example.org`, displayName: `Di ${handle}`, password, locale: 'de', }); await service.markEmailVerified(user.id); users[handle] = user; cookies[handle] = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200), ); } /** Fan out an event and backdate the resulting unmailed notifications. */ async function seedEvent(type: 'page_changed' | 'comment_added', ageMs: number): Promise { await app.get(NotificationsService).fanoutPageEvent(type, pageId, [users.owner!.id]); await prisma.notification.updateMany({ where: { userId: users.watcher!.id, mailedAt: null }, data: { createdAt: new Date(Date.now() - ageMs) }, }); } async function outboxFor(handle: string): Promise<{ subject: string; textBody: string }[]> { return prisma.mailOutbox.findMany({ where: { toAddress: users[handle]!.email }, orderBy: { createdAt: 'asc' }, select: { subject: true, textBody: true }, }); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); digest = app.get(DigestService); for (const handle of ['owner', 'watcher']) await makeUser(handle); const pond = await prisma.pond.create({ data: { slug: `di-pond-${suffix}`, name: 'Digest Pond', type: 'SHARED', ownerId: users.owner!.id, }, }); pondId = pond.id; await prisma.roleGrant.create({ data: { pondId, subjectType: 'USER', subjectId: users.owner!.id, role: 'POND_ADMIN', scopeType: 'POND', effect: 'ALLOW', createdBy: users.owner!.id, }, }); const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title: 'Digest target' }) .expect(201); pageId = (page.body as { id: string }).id; // The watcher joins as a reader through the API (permission cache!). await api() .post(`/api/v1/ponds/${pondId}/members`) .set('Cookie', cookies.owner!) .send({ usernameOrEmail: `di-watcher-${suffix}`, role: 'reader' }) .expect(201); await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.watcher!).expect(200); }); afterAll(async () => { const ids = Object.values(users).map((u) => u.id); await prisma.mailOutbox.deleteMany({ where: { toAddress: { in: Object.values(users).map((u) => u.email) } }, }); await prisma.notification.deleteMany({ where: { userId: { in: ids } } }); await prisma.watch.deleteMany({ where: { userId: { in: ids } } }); await prisma.comment.deleteMany({ where: { page: { pondId } } }); await prisma.roleGrant.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } }); await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } }); await prisma.session.deleteMany({ where: { userId: { in: ids } } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); }); it('groups two edits and a comment into exactly one localized mail', async () => { await seedEvent('page_changed', 2 * HOUR); await seedEvent('page_changed', 2 * HOUR); await seedEvent('comment_added', 2 * HOUR); expect(await digest.runOnce()).toBe(1); const mails = await outboxFor('watcher'); expect(mails).toHaveLength(1); expect(mails[0]!.subject).toContain('3'); expect(mails[0]!.textBody).toContain('Digest Pond'); expect(mails[0]!.textBody).toContain('Digest target'); expect(mails[0]!.textBody).toContain('2 Änderungen'); expect(mails[0]!.textBody).toContain('1 Kommentar'); expect(mails[0]!.textBody).toContain('Di owner'); expect(mails[0]!.textBody).toContain('/api/v1/notifications/unsubscribe?token='); // Mailed, not read — and never mailed twice. const rows = await prisma.notification.findMany({ where: { userId: users.watcher!.id } }); expect(rows.every((row) => row.mailedAt !== null && row.readAt === null)).toBe(true); expect(await digest.runOnce()).toBe(0); expect(await outboxFor('watcher')).toHaveLength(1); }); it("respects 'off' and batches 'daily' across the day", async () => { await api() .patch('/api/v1/users/me') .set('Cookie', cookies.watcher!) .send({ digestFrequency: 'off' }) .expect(200); await seedEvent('page_changed', 3 * HOUR); expect(await digest.runOnce()).toBe(0); expect(await outboxFor('watcher')).toHaveLength(1); // unchanged // Daily: a 3 h old batch is not due yet, a > 24 h old one is. await api() .patch('/api/v1/users/me') .set('Cookie', cookies.watcher!) .send({ digestFrequency: 'daily' }) .expect(200); expect(await digest.runOnce()).toBe(0); expect(await digest.runOnce(new Date(Date.now() + 25 * HOUR))).toBe(1); expect(await outboxFor('watcher')).toHaveLength(2); }); it('unsubscribes via the signed link without creating a session', async () => { await api() .patch('/api/v1/users/me') .set('Cookie', cookies.watcher!) .send({ digestFrequency: 'hourly' }) .expect(200); const token = digest.unsubscribeToken(users.watcher!.id); const res = await api().get(`/api/v1/notifications/unsubscribe?token=${token}`).expect(200); expect(res.headers['set-cookie']).toBeUndefined(); expect(res.text).toContain('Digest-Mails deaktiviert'); const user = await prisma.user.findUniqueOrThrow({ where: { id: users.watcher!.id } }); expect(user.digestFrequency).toBe('off'); // Tampered and garbage tokens are rejected. await api().get(`/api/v1/notifications/unsubscribe?token=${token}x`).expect(400); await api().get('/api/v1/notifications/unsubscribe?token=nonsense').expect(400); await api().get('/api/v1/notifications/unsubscribe').expect(400); }); it('re-checks permission at send time: revoked content never reaches the mail', async () => { await api() .patch('/api/v1/users/me') .set('Cookie', cookies.watcher!) .send({ digestFrequency: 'hourly' }) .expect(200); await seedEvent('page_changed', 2 * HOUR); // Revoke the watcher's membership through the API (cache-safe). await api() .delete(`/api/v1/ponds/${pondId}/members/${users.watcher!.id}`) .set('Cookie', cookies.owner!) .expect(204); const before = await outboxFor('watcher'); expect(await digest.runOnce()).toBe(0); expect(await outboxFor('watcher')).toHaveLength(before.length); // The batch still counts as handled — no retry loop on revoked content. const rows = await prisma.notification.findMany({ where: { userId: users.watcher!.id, mailedAt: null }, }); expect(rows).toHaveLength(0); }); });