dorfteich/apps/api/src/notifications/digest.snapshot.db.test.ts
Claude Fable 5 6d51c0d099
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
Pin the comments/notifications semantics as a regression pack (#96)
The permission-matrix pack now covers comments: reading follows page
read, writing follows the pond's commentPolicy, label-restricted editors
cannot see a secret page's thread, all under the 404-vs-403 policy. A
new API-level social pack runs the two-user watch → notify flows: the
watcher is notified, the actor never, read-all sticks server-side, a
revoked watcher receives nothing new, and the digest job runs end to end
through the system panel's manual trigger. The digest mail's structure
is pinned by a normalized vitest snapshot (grouping, counts, actors,
unsubscribe framing) — changing the mail requires an explicit snapshot
update. Both packs ran flaky-free across five consecutive local rounds.

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

137 lines
4.7 KiB
TypeScript

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<string, User> = {};
let pondId: string;
const pageIds: string[] = [];
async function makeUser(handle: string, displayName: string): Promise<void> {
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<string> {
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=<TOKEN>',
);
expect(normalized).toMatchSnapshot();
});
});