/** * T-157 — Page watchlist + WATCH_UPDATE fan-out (per-request canView recheck). * * - POST/DELETE/GET /pages/:id/watch (VIEW-level; idempotent) * - editing a watched page (block batch) notifies watchers with a * WATCH_UPDATE in-app notification * - the editor does NOT get a self-notification * - a watcher whose membership is REVOKED after watching is NOT notified * (the recheck happens at fan-out, not watch time) */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; async function mkPage(wsId: string, headers: Record, title = "Watched Page"): Promise { const res = await app.request("POST", `/api/v1/workspaces/${wsId}/pages`, { headers, body: { title } }); expect(res.status).toBe(201); return ((await res.json()) as { id: string }).id; } async function editBlock(pageId: string, headers: Record, text: string) { const res = await app.request("POST", "/api/v1/blocks/batch", { headers, body: { pageId, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text }, properties: {} } }] } }); expect(res.status).toBe(201); } async function watchTypes(user: { accessToken: string }, pageId: string) { const res = await app.request("GET", "/api/v1/notifications", { headers: authHeaders(user.accessToken!) }); expect(res.status).toBe(200); const body = (await res.json()) as { data: { type: string; link?: { id?: string } }[] }; return body.data.filter((n) => n.type === "WATCH_UPDATE" && n.link?.id === pageId); } beforeAll(async () => { app = await createTestApp(); }, 60_000); afterAll(async () => { await app?.close(); }); describe("T-157: page watchlist + WATCH_UPDATE fan-out", () => { it("watch/unwatch endpoints are VIEW-level + idempotent", async () => { const owner = await createUser(app.request, { name: "Watch Owner" }); const outsider = await createUser(app.request, { name: "Watch Outsider" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Watch WS"); const h = authHeaders(owner.accessToken!); const pageId = await mkPage(ws.workspaceId, h); const w = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: h }); expect(w.status).toBe(201); const w2 = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: h }); expect(w2.status).toBe(201); // idempotent const get = (await (await app.request("GET", `/api/v1/pages/${pageId}/watch`, { headers: h })).json()) as { watching: boolean }; expect(get.watching).toBe(true); const u = await app.request("DELETE", `/api/v1/pages/${pageId}/watch`, { headers: h }); expect(u.status).toBe(200); const get2 = (await (await app.request("GET", `/api/v1/pages/${pageId}/watch`, { headers: h })).json()) as { watching: boolean }; expect(get2.watching).toBe(false); // non-member cannot watch (no access → 403, the repo's no-leak contract) const foreign = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: authHeaders(outsider.accessToken!) }); expect(foreign.status).toBe(403); }); it("editing a watched page notifies the watcher (not the editor)", async () => { const owner = await createUser(app.request, { name: "WF Owner" }); const member = await createUser(app.request, { name: "WF Member" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "WF WS"); const oh = authHeaders(owner.accessToken!); const mh = authHeaders(member.accessToken!); const pageId = await mkPage(ws.workspaceId, oh); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER"); // the MEMBER watches; the OWNER edits const w = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: mh }); expect(w.status).toBe(201); await editBlock(pageId, oh, "the change"); const memberNotifs = await watchTypes(member, pageId); expect(memberNotifs).toHaveLength(1); const ownerNotifs = await watchTypes(owner, pageId); expect(ownerNotifs).toHaveLength(0); // editor gets no self-noise (owner didn't watch anyway) // unwatching stops future fan-out await app.request("DELETE", `/api/v1/pages/${pageId}/watch`, { headers: mh }); await editBlock(pageId, oh, "another change"); const memberAfter = await watchTypes(member, pageId); expect(memberAfter).toHaveLength(1); // unchanged }); it("a REVOKED watcher is not notified (canView recheck at fan-out)", async () => { const owner = await createUser(app.request, { name: "Rev Owner" }); const member = await createUser(app.request, { name: "Rev Member" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Rev WS"); const oh = authHeaders(owner.accessToken!); const mh = authHeaders(member.accessToken!); const pageId = await mkPage(ws.workspaceId, oh); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER"); const wr = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: mh }); expect(wr.status).toBe(201); // revoke membership AFTER the watch const rm = await app.request("DELETE", `/api/v1/workspaces/${ws.workspaceId}/members/${member.userId}`, { headers: oh }); expect(rm.status).toBe(200); await editBlock(pageId, oh, "post-revoke change"); const notifs = await watchTypes(member, pageId); expect(notifs).toHaveLength(0); }); });