1
2
8import { describe, expect, it } from "vitest";
9
10import { handlePageRealtimeFrame, type PageRealtimeActions } from "../realtime-frame-handler";
11import type { Block, RealtimeServerEvent } from "@/lib/types";
12
13
14function mkBlock(id: string): Block {
15 return {
16 id,
17 pageId: "pg_1",
18 parentId: null,
19 type: "paragraph",
20 position: "a0",
21 content: { text: id },
22 properties: {},
23 createdAt: "2026-09-26T00:00:00.000Z",
24 updatedAt: "2026-09-26T00:00:00.000Z",
25 };
26}
27
28function mkActions(): {
29 actions: PageRealtimeActions;
30 calls: Record<keyof PageRealtimeActions, unknown[][]>;
31} {
32 const calls = {
33 upsert: [],
34 delete: [],
35 patch: [],
36 upsertPresence: [],
37 removePresence: [],
38 clearBlocks: [],
39 invalidateBlocks: [],
40 } as Record<keyof PageRealtimeActions, unknown[][]>;
41 const wrap =
42 (k: keyof PageRealtimeActions) =>
43 (...args: unknown[]) => {
44 calls[k].push(args);
45 };
46 const actions: PageRealtimeActions = {
47 upsert: wrap("upsert"),
48 delete: wrap("delete"),
49 patch: wrap("patch"),
50 upsertPresence: wrap("upsertPresence"),
51 removePresence: wrap("removePresence"),
52 clearBlocks: wrap("clearBlocks"),
53 invalidateBlocks: wrap("invalidateBlocks"),
54 };
55 return { actions, calls };
56}
57
58describe("handlePageRealtimeFrame (the overlay's pure fold logic)", () => {
59 it("presence (per-peer, TASK-0093) → upsertPresence only (the server stamps the identity)", () => {
60 const { actions, calls } = mkActions();
61 const ev: RealtimeServerEvent = {
62 type: "presence",
63 pageId: "pg_1",
64 user: { id: "u1", name: "A", color: "#f00" },
65 cursor: { blockId: "blk_1", offset: 4 },
66 viewing: true,
67 lastSeen: 1_700_000_000_000,
68 };
69 handlePageRealtimeFrame(ev, actions);
70 expect(calls.upsertPresence).toEqual([
71 [
72 "pg_1",
73 {
74 userId: "u1",
75 name: "A",
76 color: "#f00",
77 cursor: { blockId: "blk_1", offset: 4 },
78 viewing: true,
79 lastSeen: 1_700_000_000_000,
80 },
81 ],
82 ]);
83 expect(calls.removePresence).toHaveLength(0);
84 expect(calls.upsert).toHaveLength(0);
85 expect(calls.clearBlocks).toHaveLength(0);
86 expect(calls.invalidateBlocks).toHaveLength(0);
87 });
88
89 it("presence with a cleared cursor (null) keeps the avatar but drops the cursor", () => {
90 const { actions, calls } = mkActions();
91 handlePageRealtimeFrame(
92 {
93 type: "presence",
94 pageId: "pg_1",
95 user: { id: "u1", name: "A", color: "#f00" },
96 cursor: null,
97 lastSeen: 1_700_000_000_000,
98 },
99 actions,
100 );
101 expect(calls.upsertPresence[0]?.[1]).toMatchObject({ userId: "u1", cursor: null });
102 });
103
104 it("presence in avatars-only mode (no cursor/viewing — over the 50-peer cap) folds fine", () => {
105 const { actions, calls } = mkActions();
106 handlePageRealtimeFrame(
107 {
108 type: "presence",
109 pageId: "pg_1",
110 user: { id: "u1", name: "A", color: "#f00" },
111 lastSeen: 1_700_000_000_000,
112 },
113 actions,
114 );
115 expect(calls.upsertPresence[0]?.[1]).toMatchObject({ userId: "u1", cursor: null });
116 });
117
118 it("presence-leave → removePresence only", () => {
119 const { actions, calls } = mkActions();
120 handlePageRealtimeFrame({ type: "presence-leave", pageId: "pg_1", userId: "u1" }, actions);
121 expect(calls.removePresence).toEqual([["pg_1", "u1"]]);
122 expect(calls.upsertPresence).toHaveLength(0);
123 expect(calls.upsert).toHaveLength(0);
124 expect(calls.clearBlocks).toHaveLength(0);
125 expect(calls.invalidateBlocks).toHaveLength(0);
126 });
127
128 it("strips the room key (page:<id>/database:<id> → the bare store key; slice 2 sends the room key)", () => {
129 const { actions, calls } = mkActions();
130 handlePageRealtimeFrame(
131 {
132 type: "presence",
133 pageId: "page:pg_1",
134 user: { id: "u1", name: "A", color: "blue" },
135 cursor: { blockId: "blk_1", offset: 2 },
136 lastSeen: 1_700_000_000_000,
137 },
138 actions,
139 );
140 expect(calls.upsertPresence[0]?.[0]).toBe("pg_1");
141 handlePageRealtimeFrame({ type: "presence-leave", pageId: "page:pg_1", userId: "u1" }, actions);
142 expect(calls.removePresence[0]?.[0]).toBe("pg_1");
143 handlePageRealtimeFrame(
144 {
145 type: "presence",
146 pageId: "database:db_1",
147 user: { id: "u1", name: "A", color: "blue" },
148 lastSeen: 1,
149 },
150 actions,
151 );
152 expect(calls.upsertPresence.at(-1)?.[0]).toBe("db_1");
153 });
154
155 it("block:upsert → upsert only (NO refetch — the blocks are carried in-frame)", () => {
156 const { actions, calls } = mkActions();
157 const b = mkBlock("blk_1");
158 handlePageRealtimeFrame({ type: "block:upsert", pageId: "pg_1", blocks: [b] }, actions);
159 expect(calls.upsert).toEqual([["pg_1", [b]]]);
160 expect(calls.invalidateBlocks).toHaveLength(0);
161 expect(calls.clearBlocks).toHaveLength(0);
162 });
163
164 it("block:delete → delete only (NO refetch — the ids are carried in-frame)", () => {
165 const { actions, calls } = mkActions();
166 handlePageRealtimeFrame(
167 { type: "block:delete", pageId: "pg_1", blockIds: ["blk_1", "blk_2"] },
168 actions,
169 );
170 expect(calls.delete).toEqual([["pg_1", ["blk_1", "blk_2"]]]);
171 expect(calls.invalidateBlocks).toHaveLength(0);
172 expect(calls.clearBlocks).toHaveLength(0);
173 });
174
175 it("block:move → patch the moved block + refetch (reorders siblings)", () => {
176 const { actions, calls } = mkActions();
177 handlePageRealtimeFrame(
178 { type: "block:move", pageId: "pg_1", blockId: "blk_1", parentId: "blk_p", position: "a1" },
179 actions,
180 );
181 expect(calls.patch).toEqual([["pg_1", "blk_1", { parentId: "blk_p", position: "a1" }]]);
182 expect(calls.invalidateBlocks).toEqual([["pg_1"]]);
183 expect(calls.clearBlocks).toHaveLength(0);
184 });
185
186 it("block:changed → clearBlocks (keeps presence) + refetch — the (b) refetch-hint", () => {
187 const { actions, calls } = mkActions();
188 handlePageRealtimeFrame({ type: "block:changed", pageId: "pg_1", version: 9 }, actions);
189
190 expect(calls.clearBlocks).toEqual([["pg_1"]]);
191 expect(calls.invalidateBlocks).toEqual([["pg_1"]]);
192
193 expect(calls.upsert).toHaveLength(0);
194 expect(calls.delete).toHaveLength(0);
195 expect(calls.patch).toHaveLength(0);
196
197 expect(calls.upsertPresence).toHaveLength(0);
198 expect(calls.removePresence).toHaveLength(0);
199 });
200
201 it("block:changed without a version (a REST write) still refetches", () => {
202 const { actions, calls } = mkActions();
203 handlePageRealtimeFrame({ type: "block:changed", pageId: "pg_1" }, actions);
204 expect(calls.clearBlocks).toEqual([["pg_1"]]);
205 expect(calls.invalidateBlocks).toEqual([["pg_1"]]);
206 });
207
208 it("comment.event / notification.push / ack are no-ops for the block overlay", () => {
209 const { actions, calls } = mkActions();
210 handlePageRealtimeFrame(
211 { type: "comment.event", kind: "created", commentId: "c1", pageId: "pg_1", actorId: "u1", preview: "hi" },
212 actions,
213 );
214 handlePageRealtimeFrame(
215 { type: "notification.push", notificationId: "n1", kind: "mention", title: "You were mentioned", deepLink: { type: "comment", id: "c1" } },
216 actions,
217 );
218 handlePageRealtimeFrame({ type: "ack", requestId: "r1" }, actions);
219 for (const k of Object.keys(calls) as (keyof typeof calls)[]) {
220 expect(calls[k]).toHaveLength(0);
221 }
222 });
223});
224