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 / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
An unused type import and an empty destructuring pattern slipped through because the lint exit code was swallowed by a pipe before the push. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
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();
|
|
});
|