import { INestApplication } from '@nestjs/common'; import { PrismaClient, User } from '@prisma/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { DigestService } from './digest.service'; import { NotificationsService } from './notifications.service'; import { createTestApp } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; const HOUR = 60 * 60 * 1000; /** * Pins the digest mail's structure (issue #96): grouped per pond → per * page with counts and actors, intro/open/unsubscribe framing. Changing * the mail requires an explicit snapshot update — that is the point. * Dynamic parts (signed token) are normalized before snapshotting. */ describe.skipIf(!hasTestDb)('digest mail structure snapshot (issue #96)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const users: Record = {}; let pondId: string; const pageIds: string[] = []; async function makeUser(handle: string, displayName: string): Promise { const service = app.get(UsersService); const user = await service.createUser({ username: `ds-${handle}-${suffix}`, email: `ds-${handle}-${suffix}@example.org`, displayName, password: 'schnappschuss bleibt stabil 1', locale: 'de', }); await service.markEmailVerified(user.id); users[handle] = user; } async function makePage(title: string, slug: string): Promise { const page = await prisma.page.create({ data: { pondId, title, slug, ydocState: new Uint8Array(), sortKey: `a${slug}`, createdBy: users.owner!.id, }, }); pageIds.push(page.id); return page.id; } beforeAll(async () => { prisma = createTestPrisma(); app = await createTestApp(); await makeUser('owner', 'Anna Autorin'); await makeUser('watcher', 'Willi Watcher'); const pond = await prisma.pond.create({ data: { slug: `ds-pond-${suffix}`, name: 'Snapshot Pond', type: 'SHARED', ownerId: users.owner!.id, }, }); pondId = pond.id; for (const [handle, role] of [ ['owner', 'POND_ADMIN'], ['watcher', 'READER'], ] as const) { await prisma.roleGrant.create({ data: { pondId, subjectType: 'USER', subjectId: users[handle]!.id, role, scopeType: 'POND', effect: 'ALLOW', createdBy: users.owner!.id, }, }); } }); 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.roleGrant.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); }); it('renders the grouped digest exactly as pinned', async () => { const notes = await makePage('Notizen', 'notizen'); const plan = await makePage('Plan', 'plan'); await prisma.watch.createMany({ data: [ { userId: users.watcher!.id, targetType: 'PAGE', targetId: notes }, { userId: users.watcher!.id, targetType: 'PAGE', targetId: plan }, ], }); const notifications = app.get(NotificationsService); await notifications.fanoutPageEvent('page_changed', notes, [users.owner!.id]); await notifications.fanoutPageEvent('page_changed', notes, [users.owner!.id]); await notifications.fanoutPageEvent('comment_added', notes, [users.owner!.id]); await notifications.fanoutPageEvent('page_changed', plan, [users.owner!.id]); await prisma.notification.updateMany({ where: { userId: users.watcher!.id }, data: { createdAt: new Date(Date.now() - 2 * HOUR) }, }); expect(await app.get(DigestService).runOnce()).toBe(1); const mail = await prisma.mailOutbox.findFirstOrThrow({ where: { toAddress: users.watcher!.email }, orderBy: { createdAt: 'desc' }, }); // Normalize the signed token — everything else must be stable. const normalized = `${mail.subject}\n---\n${mail.textBody}`.replace( /token=[A-Za-z0-9_.-]+/g, 'token=', ); expect(normalized).toMatchSnapshot(); }); });