dorfteich/apps/api/src/notifications/digest.e2e.db.test.ts
Claude Fable 5 9674c0bae2
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m41s
CD / Build and push images (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m53s
CI / Import/export fidelity gate (push) Has been skipped
Batch notifications into localized e-mail digests with unsubscribe (#95)
New per-user digestFrequency (hourly default | daily | off) on the
profile and in the settings UI. A scheduler job (15 min cadence) mails a
user once their oldest unread, unmailed notification exceeds the cadence
window: one localized mail per batch, grouped per pond then per page
with actor names and change/comment counts, enqueued through the mail
outbox. Sending marks the batch mailed — never read — and re-checks page
read permission per entry at send time; entries the user can no longer
read are dropped from the mail but still marked handled, so revoked
content cannot queue forever. Every mail carries a signed, single-purpose
unsubscribe link: it only flips the setting to off, renders a session-free
confirmation page, and sets no cookie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 23:37:26 +02:00

218 lines
8.3 KiB
TypeScript

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<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pageId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
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<void> {
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);
});
});