import { expect, test } from '@playwright/test'; import type { 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 () => { 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(); });