1
11import { afterAll, beforeAll, describe, expect, it } from "vitest";
12import { createTestApp, type TestAppHandle } from "./helpers/api";
13import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
14
15let app: TestAppHandle;
16
17async function mkPage(wsId: string, headers: Record<string, string>, title = "Watched Page"): Promise<string> {
18 const res = await app.request("POST", `/api/v1/workspaces/${wsId}/pages`, { headers, body: { title } });
19 expect(res.status).toBe(201);
20 return ((await res.json()) as { id: string }).id;
21}
22
23async function editBlock(pageId: string, headers: Record<string, string>, text: string) {
24 const res = await app.request("POST", "/api/v1/blocks/batch", {
25 headers,
26 body: { pageId, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text }, properties: {} } }] }
27 });
28 expect(res.status).toBe(201);
29}
30
31async function watchTypes(user: { accessToken: string }, pageId: string) {
32 const res = await app.request("GET", "/api/v1/notifications", { headers: authHeaders(user.accessToken!) });
33 expect(res.status).toBe(200);
34 const body = (await res.json()) as { data: { type: string; link?: { id?: string } }[] };
35 return body.data.filter((n) => n.type === "WATCH_UPDATE" && n.link?.id === pageId);
36}
37
38beforeAll(async () => {
39 app = await createTestApp();
40}, 60_000);
41afterAll(async () => {
42 await app?.close();
43});
44
45describe("T-157: page watchlist + WATCH_UPDATE fan-out", () => {
46 it("watch/unwatch endpoints are VIEW-level + idempotent", async () => {
47 const owner = await createUser(app.request, { name: "Watch Owner" });
48 const outsider = await createUser(app.request, { name: "Watch Outsider" });
49 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Watch WS");
50 const h = authHeaders(owner.accessToken!);
51 const pageId = await mkPage(ws.workspaceId, h);
52
53 const w = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: h });
54 expect(w.status).toBe(201);
55 const w2 = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: h });
56 expect(w2.status).toBe(201);
57 const get = (await (await app.request("GET", `/api/v1/pages/${pageId}/watch`, { headers: h })).json()) as { watching: boolean };
58 expect(get.watching).toBe(true);
59 const u = await app.request("DELETE", `/api/v1/pages/${pageId}/watch`, { headers: h });
60 expect(u.status).toBe(200);
61 const get2 = (await (await app.request("GET", `/api/v1/pages/${pageId}/watch`, { headers: h })).json()) as { watching: boolean };
62 expect(get2.watching).toBe(false);
63
64
65 const foreign = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: authHeaders(outsider.accessToken!) });
66 expect(foreign.status).toBe(403);
67 });
68
69 it("editing a watched page notifies the watcher (not the editor)", async () => {
70 const owner = await createUser(app.request, { name: "WF Owner" });
71 const member = await createUser(app.request, { name: "WF Member" });
72 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "WF WS");
73 const oh = authHeaders(owner.accessToken!);
74 const mh = authHeaders(member.accessToken!);
75 const pageId = await mkPage(ws.workspaceId, oh);
76
77 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
78
79 const w = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: mh });
80 expect(w.status).toBe(201);
81
82 await editBlock(pageId, oh, "the change");
83
84 const memberNotifs = await watchTypes(member, pageId);
85 expect(memberNotifs).toHaveLength(1);
86 const ownerNotifs = await watchTypes(owner, pageId);
87 expect(ownerNotifs).toHaveLength(0);
88
89
90 await app.request("DELETE", `/api/v1/pages/${pageId}/watch`, { headers: mh });
91 await editBlock(pageId, oh, "another change");
92 const memberAfter = await watchTypes(member, pageId);
93 expect(memberAfter).toHaveLength(1);
94 });
95
96 it("a REVOKED watcher is not notified (canView recheck at fan-out)", async () => {
97 const owner = await createUser(app.request, { name: "Rev Owner" });
98 const member = await createUser(app.request, { name: "Rev Member" });
99 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Rev WS");
100 const oh = authHeaders(owner.accessToken!);
101 const mh = authHeaders(member.accessToken!);
102 const pageId = await mkPage(ws.workspaceId, oh);
103
104 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
105 const wr = await app.request("POST", `/api/v1/pages/${pageId}/watch`, { headers: mh });
106 expect(wr.status).toBe(201);
107
108
109 const rm = await app.request("DELETE", `/api/v1/workspaces/${ws.workspaceId}/members/${member.userId}`, { headers: oh });
110 expect(rm.status).toBe(200);
111
112 await editBlock(pageId, oh, "post-revoke change");
113 const notifs = await watchTypes(member, pageId);
114 expect(notifs).toHaveLength(0);
115 });
116});
117