Pin the comments/notifications semantics as a regression pack (#96)
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
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
This commit is contained in:
parent
9ee9bbe4f0
commit
6d51c0d099
@ -232,6 +232,16 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/comments.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/comments.spec.ts
|
||||||
|
|
||||||
|
- name: Reset login rate limit before social pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
- name: Run social pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before admin-quotas pack
|
- name: Reset login rate limit before admin-quotas pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`digest mail structure snapshot (issue #96) > renders the grouped digest exactly as pinned 1`] = `
|
||||||
|
"Dorfteich: 4 Neuigkeiten für dich
|
||||||
|
---
|
||||||
|
Das ist auf von dir beobachteten Seiten passiert (4 Neuigkeiten):
|
||||||
|
|
||||||
|
Snapshot Pond:
|
||||||
|
- Notizen: 2 Änderungen, 1 Kommentar (Anna Autorin)
|
||||||
|
- Plan: 1 Änderung (Anna Autorin)
|
||||||
|
|
||||||
|
Dorfteich öffnen: http://localhost:5173
|
||||||
|
|
||||||
|
Diese Digest-Mails abbestellen: http://localhost:5173/api/v1/notifications/unsubscribe?token=<TOKEN>"
|
||||||
|
`;
|
||||||
136
apps/api/src/notifications/digest.snapshot.db.test.ts
Normal file
136
apps/api/src/notifications/digest.snapshot.db.test.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -16,7 +16,9 @@ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
|||||||
* Subjects: site admin, pond admin (owner), editor, label-restricted editor
|
* Subjects: site admin, pond admin (owner), editor, label-restricted editor
|
||||||
* (same account, blocked on a "secret" label), reader, public (anonymous),
|
* (same account, blocked on a "secret" label), reader, public (anonymous),
|
||||||
* foreign (signed-in non-member). Surfaces: page read, edit (collab token
|
* foreign (signed-in non-member). Surfaces: page read, edit (collab token
|
||||||
* mode), sidebar list, search, versions, media, public HTML.
|
* mode), sidebar list, search, versions, media, public HTML — and, since
|
||||||
|
* issue #96, comments: reading follows page read, writing follows the pond's
|
||||||
|
* `commentPolicy` (readers | editors), both under the same 404-vs-403 policy.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Fixture {
|
interface Fixture {
|
||||||
@ -206,3 +208,33 @@ test('versions require write; media follows page read; public HTML honours grant
|
|||||||
expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.publicPage.slug}`)).toBe(200);
|
expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.publicPage.slug}`)).toBe(200);
|
||||||
expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.normalPage.slug}`)).toBe(404);
|
expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.normalPage.slug}`)).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('comments follow page read plus the pond comment policy (issue #96)', async () => {
|
||||||
|
const post = (c: APIRequestContext, pageId: string): Promise<number> =>
|
||||||
|
c
|
||||||
|
.post(`/api/v1/pages/${pageId}/comments`, { data: { body: 'matrix probe' } })
|
||||||
|
.then((r) => r.status());
|
||||||
|
const list = (c: APIRequestContext, pageId: string): Promise<number> =>
|
||||||
|
status(c, `/api/v1/pages/${pageId}/comments`);
|
||||||
|
|
||||||
|
// Default policy (readers): every reader writes, hidden pages stay hidden.
|
||||||
|
expect(await post(f.owner.request, f.normalPage.id)).toBe(201);
|
||||||
|
expect(await post(f.editor.request, f.normalPage.id)).toBe(201);
|
||||||
|
expect(await post(f.reader.request, f.normalPage.id)).toBe(201);
|
||||||
|
expect(await post(f.outsider.request, f.normalPage.id)).toBe(404);
|
||||||
|
expect(await post(f.anon, f.normalPage.id)).toBe(401);
|
||||||
|
// The label-restricted editor cannot even see the secret page's thread.
|
||||||
|
expect(await post(f.editor.request, f.secretPage.id)).toBe(404);
|
||||||
|
expect(await list(f.editor.request, f.secretPage.id)).toBe(404);
|
||||||
|
expect(await list(f.reader.request, f.normalPage.id)).toBe(200);
|
||||||
|
|
||||||
|
// Editors-only policy: readable-but-barred is an explicit 403.
|
||||||
|
const patched = await f.owner.request.patch(`/api/v1/ponds/${f.pondId}`, {
|
||||||
|
data: { commentPolicy: 'editors' },
|
||||||
|
});
|
||||||
|
expect(patched.ok()).toBe(true);
|
||||||
|
expect(await post(f.reader.request, f.normalPage.id)).toBe(403);
|
||||||
|
expect(await post(f.editor.request, f.normalPage.id)).toBe(201);
|
||||||
|
expect(await post(f.outsider.request, f.normalPage.id)).toBe(404);
|
||||||
|
expect(await list(f.reader.request, f.normalPage.id)).toBe(200); // reading stays open
|
||||||
|
});
|
||||||
|
|||||||
110
apps/web/e2e/social.spec.ts
Normal file
110
apps/web/e2e/social.spec.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { APIRequestContext, BrowserContext } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch → notify regression flows with two users (issue #96). API-level on
|
||||||
|
* purpose — the UI adds nothing over the delivered notification rows (the
|
||||||
|
* bell is plain rendering of this API; the comments UI has its own pack).
|
||||||
|
* Fixtures: fixture-user owns a throwaway pond (the seed grants fixture
|
||||||
|
* users additional-pond headroom), fixture-viewer is its reader/watcher.
|
||||||
|
* Digest *semantics* are pinned by the DB suite + the mail-structure
|
||||||
|
* snapshot (issue #95); here the job only proves it runs end to end.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let owner: BrowserContext;
|
||||||
|
let watcher: BrowserContext;
|
||||||
|
let pondId: string;
|
||||||
|
let pageId: string;
|
||||||
|
let watcherId: string;
|
||||||
|
|
||||||
|
async function notificationsFor(
|
||||||
|
ctx: BrowserContext,
|
||||||
|
): Promise<{
|
||||||
|
unreadCount: number;
|
||||||
|
notifications: { type: string; payload: { pageId: string } }[];
|
||||||
|
}> {
|
||||||
|
const res = await ctx.request.get('/api/v1/notifications');
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return (await res.json()) as {
|
||||||
|
unreadCount: number;
|
||||||
|
notifications: { type: string; payload: { pageId: string } }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const forPage = (list: { notifications: { type: string; payload: { pageId: string } }[] }) =>
|
||||||
|
list.notifications.filter((n) => n.payload.pageId === pageId);
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
watcher = await contextForUser(browser, BASE_URL, 'fixture-viewer');
|
||||||
|
|
||||||
|
const pond = (await (
|
||||||
|
await owner.request.post('/api/v1/ponds', { data: { name: `Social ${Date.now()}` } })
|
||||||
|
).json()) as { id: string };
|
||||||
|
pondId = pond.id;
|
||||||
|
const page = (await (
|
||||||
|
await owner.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title: 'Social target' } })
|
||||||
|
).json()) as { id: string };
|
||||||
|
pageId = page.id;
|
||||||
|
|
||||||
|
const member = await owner.request.post(`/api/v1/ponds/${pondId}/members`, {
|
||||||
|
data: { usernameOrEmail: 'fixture-viewer', role: 'reader' },
|
||||||
|
});
|
||||||
|
expect(member.ok()).toBe(true);
|
||||||
|
watcherId = ((await (await watcher.request.get('/api/v1/auth/me')).json()) as { id: string }).id;
|
||||||
|
const watch = await watcher.request.put(`/api/v1/watches/page/${pageId}`);
|
||||||
|
expect(watch.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await watcher.request.post('/api/v1/notifications/read-all');
|
||||||
|
await owner.request.delete(`/api/v1/ponds/${pondId}`);
|
||||||
|
await owner.close();
|
||||||
|
await watcher.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comment notifies the watcher, never the actor, and read state sticks', async ({}, testInfo) => {
|
||||||
|
testInfo.setTimeout(20_000);
|
||||||
|
const commented = await owner.request.post(`/api/v1/pages/${pageId}/comments`, {
|
||||||
|
data: { body: 'ping the watchers' },
|
||||||
|
});
|
||||||
|
expect(commented.ok()).toBe(true);
|
||||||
|
|
||||||
|
const watcherList = await notificationsFor(watcher);
|
||||||
|
const mine = forPage(watcherList);
|
||||||
|
expect(mine.length).toBe(1);
|
||||||
|
expect(mine[0]!.type).toBe('comment_added');
|
||||||
|
expect(watcherList.unreadCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The actor got nothing for their own comment.
|
||||||
|
expect(forPage(await notificationsFor(owner)).length).toBe(0);
|
||||||
|
|
||||||
|
// Read-all clears the badge; a fresh request still sees it (server state).
|
||||||
|
await watcher.request.post('/api/v1/notifications/read-all');
|
||||||
|
expect((await notificationsFor(watcher)).unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a revoked watcher receives nothing new (delivery-time re-check)', async () => {
|
||||||
|
const before = forPage(await notificationsFor(watcher)).length;
|
||||||
|
|
||||||
|
const removed = await owner.request.delete(`/api/v1/ponds/${pondId}/members/${watcherId}`);
|
||||||
|
expect(removed.ok()).toBe(true);
|
||||||
|
const commented = await owner.request.post(`/api/v1/pages/${pageId}/comments`, {
|
||||||
|
data: { body: 'the revoked watcher must not hear this' },
|
||||||
|
});
|
||||||
|
expect(commented.ok()).toBe(true);
|
||||||
|
|
||||||
|
expect(forPage(await notificationsFor(watcher)).length).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the digest job runs end to end via the manual trigger', async ({ browser }) => {
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
const res = await admin.request.post('/api/v1/admin/system/jobs/notification-digest/run');
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
expect(((await res.json()) as { outcome: string }).outcome).toBe('succeeded');
|
||||||
|
await admin.close();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user