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
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
218 lines
8.3 KiB
TypeScript
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);
|
|
});
|
|
});
|