// @vitest-environment node /** * Pure unit test of the overlay's frame fold logic — no DOM, no socket, no * store (the actions are spies). Runs in the node environment (the web * default is jsdom, which is not installed in this workspace; this test * needs none of it). */ import { describe, expect, it } from "vitest"; import { handlePageRealtimeFrame, type PageRealtimeActions } from "../realtime-frame-handler"; import type { Block, RealtimeServerEvent } from "@/lib/types"; /** A minimal but fully-shaped web Block (for `block:upsert` frames). */ function mkBlock(id: string): Block { return { id, pageId: "pg_1", parentId: null, type: "paragraph", position: "a0", content: { text: id }, properties: {}, createdAt: "2026-09-26T00:00:00.000Z", updatedAt: "2026-09-26T00:00:00.000Z", }; } function mkActions(): { actions: PageRealtimeActions; calls: Record; } { const calls = { upsert: [], delete: [], patch: [], upsertPresence: [], removePresence: [], clearBlocks: [], invalidateBlocks: [], } as Record; const wrap = (k: keyof PageRealtimeActions) => (...args: unknown[]) => { calls[k].push(args); }; const actions: PageRealtimeActions = { upsert: wrap("upsert"), delete: wrap("delete"), patch: wrap("patch"), upsertPresence: wrap("upsertPresence"), removePresence: wrap("removePresence"), clearBlocks: wrap("clearBlocks"), invalidateBlocks: wrap("invalidateBlocks"), }; return { actions, calls }; } describe("handlePageRealtimeFrame (the overlay's pure fold logic)", () => { it("presence (per-peer, TASK-0093) → upsertPresence only (the server stamps the identity)", () => { const { actions, calls } = mkActions(); const ev: RealtimeServerEvent = { type: "presence", pageId: "pg_1", user: { id: "u1", name: "A", color: "#f00" }, cursor: { blockId: "blk_1", offset: 4 }, viewing: true, lastSeen: 1_700_000_000_000, }; handlePageRealtimeFrame(ev, actions); expect(calls.upsertPresence).toEqual([ [ "pg_1", { userId: "u1", name: "A", color: "#f00", cursor: { blockId: "blk_1", offset: 4 }, viewing: true, lastSeen: 1_700_000_000_000, }, ], ]); expect(calls.removePresence).toHaveLength(0); expect(calls.upsert).toHaveLength(0); expect(calls.clearBlocks).toHaveLength(0); expect(calls.invalidateBlocks).toHaveLength(0); }); it("presence with a cleared cursor (null) keeps the avatar but drops the cursor", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame( { type: "presence", pageId: "pg_1", user: { id: "u1", name: "A", color: "#f00" }, cursor: null, lastSeen: 1_700_000_000_000, }, actions, ); expect(calls.upsertPresence[0]?.[1]).toMatchObject({ userId: "u1", cursor: null }); }); it("presence in avatars-only mode (no cursor/viewing — over the 50-peer cap) folds fine", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame( { type: "presence", pageId: "pg_1", user: { id: "u1", name: "A", color: "#f00" }, lastSeen: 1_700_000_000_000, }, actions, ); expect(calls.upsertPresence[0]?.[1]).toMatchObject({ userId: "u1", cursor: null }); }); it("presence-leave → removePresence only", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame({ type: "presence-leave", pageId: "pg_1", userId: "u1" }, actions); expect(calls.removePresence).toEqual([["pg_1", "u1"]]); expect(calls.upsertPresence).toHaveLength(0); expect(calls.upsert).toHaveLength(0); expect(calls.clearBlocks).toHaveLength(0); expect(calls.invalidateBlocks).toHaveLength(0); }); it("strips the room key (page:/database: → the bare store key; slice 2 sends the room key)", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame( { type: "presence", pageId: "page:pg_1", user: { id: "u1", name: "A", color: "blue" }, cursor: { blockId: "blk_1", offset: 2 }, lastSeen: 1_700_000_000_000, }, actions, ); expect(calls.upsertPresence[0]?.[0]).toBe("pg_1"); // room key → bare pageId handlePageRealtimeFrame({ type: "presence-leave", pageId: "page:pg_1", userId: "u1" }, actions); expect(calls.removePresence[0]?.[0]).toBe("pg_1"); handlePageRealtimeFrame( { type: "presence", pageId: "database:db_1", user: { id: "u1", name: "A", color: "blue" }, lastSeen: 1, }, actions, ); expect(calls.upsertPresence.at(-1)?.[0]).toBe("db_1"); // database room → database id }); it("block:upsert → upsert only (NO refetch — the blocks are carried in-frame)", () => { const { actions, calls } = mkActions(); const b = mkBlock("blk_1"); handlePageRealtimeFrame({ type: "block:upsert", pageId: "pg_1", blocks: [b] }, actions); expect(calls.upsert).toEqual([["pg_1", [b]]]); expect(calls.invalidateBlocks).toHaveLength(0); expect(calls.clearBlocks).toHaveLength(0); }); it("block:delete → delete only (NO refetch — the ids are carried in-frame)", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame( { type: "block:delete", pageId: "pg_1", blockIds: ["blk_1", "blk_2"] }, actions, ); expect(calls.delete).toEqual([["pg_1", ["blk_1", "blk_2"]]]); expect(calls.invalidateBlocks).toHaveLength(0); expect(calls.clearBlocks).toHaveLength(0); }); it("block:move → patch the moved block + refetch (reorders siblings)", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame( { type: "block:move", pageId: "pg_1", blockId: "blk_1", parentId: "blk_p", position: "a1" }, actions, ); expect(calls.patch).toEqual([["pg_1", "blk_1", { parentId: "blk_p", position: "a1" }]]); expect(calls.invalidateBlocks).toEqual([["pg_1"]]); expect(calls.clearBlocks).toHaveLength(0); }); it("block:changed → clearBlocks (keeps presence) + refetch — the (b) refetch-hint", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame({ type: "block:changed", pageId: "pg_1", version: 9 }, actions); // The refetch-hint: drop the block overlay AND refetch the page. expect(calls.clearBlocks).toEqual([["pg_1"]]); expect(calls.invalidateBlocks).toEqual([["pg_1"]]); // It does NOT replay per-block frames (that's the point of (b)). expect(calls.upsert).toHaveLength(0); expect(calls.delete).toHaveLength(0); expect(calls.patch).toHaveLength(0); // …and it does NOT touch presence (clearBlocks ≠ reset). expect(calls.upsertPresence).toHaveLength(0); expect(calls.removePresence).toHaveLength(0); }); it("block:changed without a version (a REST write) still refetches", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame({ type: "block:changed", pageId: "pg_1" }, actions); expect(calls.clearBlocks).toEqual([["pg_1"]]); expect(calls.invalidateBlocks).toEqual([["pg_1"]]); }); it("comment.event / notification.push / ack are no-ops for the block overlay", () => { const { actions, calls } = mkActions(); handlePageRealtimeFrame( { type: "comment.event", kind: "created", commentId: "c1", pageId: "pg_1", actorId: "u1", preview: "hi" }, actions, ); handlePageRealtimeFrame( { type: "notification.push", notificationId: "n1", kind: "mention", title: "You were mentioned", deepLink: { type: "comment", id: "c1" } }, actions, ); handlePageRealtimeFrame({ type: "ack", requestId: "r1" }, actions); for (const k of Object.keys(calls) as (keyof typeof calls)[]) { expect(calls[k]).toHaveLength(0); } }); });