1
19import { afterAll, beforeAll, describe, expect, it } from "vitest";
20
21import {
22 buildRealtimeFixture,
23 connectClient,
24 makeFakeDomainEventSource,
25 mintToken,
26 type RealtimeFixture,
27} from "./rt-helpers";
28
29interface FixtureWithSource {
30 fx: RealtimeFixture;
31 source: ReturnType<typeof makeFakeDomainEventSource>;
32}
33
34let suite: FixtureWithSource | null = null;
35
36beforeAll(async () => {
37 const source = makeFakeDomainEventSource();
38 const fx = await buildRealtimeFixture({
39 pages: new Set(["pg_1", "pg_2"]),
40 workspaces: { pg_1: "w1", pg_2: "w1" },
41 userNames: { u1: "Ana", u2: "Ben" },
42 domainEventSource: source.factory,
43 });
44 suite = { fx, source };
45});
46
47afterAll(async () => {
48 await suite?.fx.stop();
49 suite = null;
50});
51
52
53async function connectCommentRoom(fx: RealtimeFixture, user: string, pageId: string) {
54 const token = mintToken({ sub: user, workspaces: ["w1"] });
55 const url = `${fx.wsUrl}/realtime?token=${encodeURIComponent(token)}&room=${encodeURIComponent(`comment:${pageId}`)}`;
56 return connectClient(fx, { url });
57}
58
59
60async function connectControl(fx: RealtimeFixture, user: string) {
61 const token = mintToken({ sub: user, workspaces: ["w1"] });
62 const url = `${fx.wsUrl}/realtime?token=${encodeURIComponent(token)}`;
63 return connectClient(fx, { url });
64}
65
66describe("comment.event fan-out (TASK-0094 R1)", () => {
67 it("reaches a comment:{pageId} doc-room socket (kind 'created' + preview)", async () => {
68 const { fx, source } = suite!;
69 const c = await connectCommentRoom(fx, "u1", "pg_1");
70 await fx.settle(20);
71 const p = c.nextFrame("comment.event");
72 source.deliver("comment.created", {
73 workspaceId: "w1",
74 commentId: "c_1",
75 pageId: "pg_1",
76 actorId: "u2",
77 body: "hello from Ben",
78 });
79 const frame = await p;
80 expect(frame).toMatchObject({
81 type: "comment.event",
82 kind: "created",
83 commentId: "c_1",
84 pageId: "pg_1",
85 actorId: "u2",
86 preview: "hello from Ben",
87 });
88 c.close();
89 });
90
91 it("reaches a roomless CONTROL socket with a page:{pageId} channel (the (b) web client)", async () => {
92 const { fx, source } = suite!;
93 const c = await connectControl(fx, "u1");
94 c.sendText({ type: "subscribe", channel: "page:pg_1" });
95 await fx.settle(20);
96 const p = c.nextFrame("comment.event");
97 source.deliver("comment.created", {
98 workspaceId: "w1",
99 commentId: "c_2",
100 replyId: "r_2",
101 pageId: "pg_1",
102 actorId: "u2",
103 body: "a reply",
104 });
105 const frame = await p;
106
107 expect(frame).toMatchObject({ type: "comment.event", kind: "replied", commentId: "c_2" });
108 c.close();
109 });
110
111 it("does NOT reach a socket subscribed to a DIFFERENT page's channel", async () => {
112 const { fx, source } = suite!;
113 const c = await connectControl(fx, "u1");
114 c.sendText({ type: "subscribe", channel: "page:pg_2" });
115 await fx.settle(20);
116 source.deliver("comment.created", {
117 workspaceId: "w1",
118 commentId: "c_3",
119 pageId: "pg_1",
120 actorId: "u2",
121 body: "for pg_1 only",
122 });
123 await fx.settle(50);
124
125 const got = c.textFrames().filter((f) => f.type === "comment.event");
126 expect(got).toHaveLength(0);
127 c.close();
128 });
129
130 it("does NOT reach a PAGE doc-room socket (comment rooms + page channels only — recorded)", async () => {
131 const { fx, source } = suite!;
132
133 const c = await connectClient(fx, {
134 token: mintToken({ sub: "u1", workspaces: ["w1"] }),
135 room: "page:pg_1",
136 });
137 await fx.settle(20);
138 source.deliver("comment.created", {
139 workspaceId: "w1",
140 commentId: "c_4",
141 pageId: "pg_1",
142 actorId: "u2",
143 body: "not for doc-room sockets",
144 });
145 await fx.settle(50);
146 const got = c.textFrames().filter((f) => f.type === "comment.event");
147 expect(got).toHaveLength(0);
148 c.close();
149 });
150
151 it("fans resolved/reopened/reacted/deleted kinds to the page channel", async () => {
152 const { fx, source } = suite!;
153 const c = await connectControl(fx, "u1");
154 c.sendText({ type: "subscribe", channel: "page:pg_1" });
155 await fx.settle(20);
156 const pResolved = c.nextFrameWhere((f) => f.type === "comment.event" && f.kind === "resolved");
157 const pReacted = c.nextFrameWhere((f) => f.type === "comment.event" && f.kind === "reacted");
158 source.deliver("comment.resolved", {
159 workspaceId: "w1",
160 commentId: "c_5",
161 pageId: "pg_1",
162 actorId: "u1",
163 });
164 source.deliver("comment.reaction", {
165 workspaceId: "w1",
166 commentId: "c_5",
167 pageId: "pg_1",
168 actorId: "u2",
169 emoji: "👍",
170 active: true,
171 });
172 expect(await pResolved).toMatchObject({ commentId: "c_5", kind: "resolved", preview: "" });
173 expect(await pReacted).toMatchObject({ commentId: "c_5", kind: "reacted" });
174 c.close();
175 });
176});
177
178describe("notification.push control-room fan-out (TASK-0094 R7)", () => {
179 it("reaches the RECIPIENT's control socket (all their tabs) with the frame fields", async () => {
180 const { fx, source } = suite!;
181
182 const c1 = await connectControl(fx, "u1");
183 const c2 = await connectControl(fx, "u1");
184 await fx.settle(20);
185 const p1 = c1.nextFrame("notification.push");
186 const p2 = c2.nextFrame("notification.push");
187 source.deliver("notification.created", {
188 workspaceId: "w1",
189 notificationId: "n_1",
190 userId: "u1",
191 type: "MENTION",
192 title: "You were mentioned in a comment",
193 deepLink: { type: "comment", id: "c_9" },
194 actorId: "u2",
195 actorName: "Ben",
196 });
197 const f1 = await p1;
198 const f2 = await p2;
199 for (const f of [f1, f2]) {
200 expect(f).toMatchObject({
201 type: "notification.push",
202 notificationId: "n_1",
203 kind: "mention",
204 title: "You were mentioned in a comment",
205 deepLink: { type: "comment", id: "c_9" },
206 actorName: "Ben",
207 });
208 }
209 c1.close();
210 c2.close();
211 });
212
213 it("ISOLATION: a notification for user X never appears on user Y's control socket", async () => {
214 const { fx, source } = suite!;
215 const other = await connectControl(fx, "u2");
216 await fx.settle(20);
217 source.deliver("notification.created", {
218 workspaceId: "w1",
219 notificationId: "n_2",
220 userId: "u1",
221 type: "COMMENT",
222 title: "Ben replied in a thread",
223 deepLink: { type: "comment", id: "c_10" },
224 actorName: "Ben",
225 });
226 await fx.settle(60);
227 const got = other.textFrames().filter((f) => f.type === "notification.push");
228 expect(got).toHaveLength(0);
229 other.close();
230 });
231
232 it("a user with NO live sockets → no push error (the fan-out is a no-op)", async () => {
233 const { fx, source } = suite!;
234
235 expect(() =>
236 source.deliver("notification.created", {
237 workspaceId: "w1",
238 notificationId: "n_3",
239 userId: "u9",
240 type: "ASSIGNMENT",
241 title: "Assigned to you",
242 deepLink: { type: "record", id: "r_1" },
243 }),
244 ).not.toThrow();
245 });
246
247 it("unknown types still push (conservative kind — no crash)", async () => {
248 const { fx, source } = suite!;
249 const c = await connectControl(fx, "u1");
250 await fx.settle(20);
251 const p = c.nextFrame("notification.push");
252 source.deliver("notification.created", {
253 workspaceId: "w1",
254 notificationId: "n_4",
255 userId: "u1",
256 type: "SOME_FUTURE_TYPE",
257 title: "t",
258 deepLink: { type: "page", id: "pg_1" },
259 });
260 expect(await p).toMatchObject({ notificationId: "n_4", kind: "some_future_type" });
261 c.close();
262 });
263});
264
265describe("the generic source routes page.updated too (TASK-0092 6b regression)", () => {
266 it("page.updated → block:changed on the page channel (same source as comments)", async () => {
267 const { fx, source } = suite!;
268 const c = await connectControl(fx, "u1");
269 c.sendText({ type: "subscribe", channel: "page:pg_1" });
270 await fx.settle(20);
271 const p = c.nextFrame("block:changed");
272 source.deliver("page.updated", { pageId: "pg_1", version: 12 });
273 expect(await p).toMatchObject({ type: "block:changed", pageId: "pg_1", version: 12 });
274 c.close();
275 });
276
277 it("an unrelated event (record.created) produces NO control frames", async () => {
278 const { fx, source } = suite!;
279 const c = await connectControl(fx, "u1");
280 c.sendText({ type: "subscribe", channel: "page:pg_1" });
281 await fx.settle(20);
282 source.deliver("record.created", { workspaceId: "w1", recordId: "r_9", databaseId: "d_1" });
283 await fx.settle(50);
284 const controlFrames = c
285 .textFrames()
286 .filter(
287 (f) =>
288 f.type === "comment.event" ||
289 f.type === "notification.push" ||
290 f.type === "block:changed",
291 );
292 expect(controlFrames).toHaveLength(0);
293 c.close();
294 });
295});
296