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
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
137 lines
4.7 KiB
TypeScript
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();
|
|
});
|
|
});
|