import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { type ReactNode, useContext, useState } from "react"; import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; import { WSProvider } from "../../../contexts/ws/WSProvider.tsx"; import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts"; import { useWS, useWSChat, useWSEvent, useWSPresence, } from "../../../hooks/ws/useWS.ts"; import type { WSEvents } from "../../../contexts/ws/WSContext.ts"; import { useAuth } from "../../../hooks/auth/useAuth.ts"; import { FakeWebSocket } from "../../ws.ts"; import { defaultRoutes } from "../../render.tsx"; import { makeChatMessage, makeComment, makeDump, makeNotification, makePlaylist, makeSession, makeUser, storeSession, wire, } from "../../fixtures.ts"; const me = makeSession({ id: "user-me", username: "me" }); function wrapper({ children }: { children: ReactNode }) { return ( {children} ); } /** Every event of `kind` the provider emitted, oldest first. */ function useEvents(kind: K): WSEvents[K][] { // One array for the hook's life, which the subscription fills. const [received] = useState(() => []); useWSEvent(kind, (event) => received.push(event)); return received; } function setup({ signedIn = true } = {}) { if (signedIn) storeSession(me); defaultRoutes({ session: signedIn ? me : null }); const hook = renderHook( () => ({ ws: { ...useWS(), ...useWSChat(), ...useWSPresence() }, auth: useAuth(), avatars: useContext(AvatarOverrideContext), events: { vote: useEvents("vote"), dump: useEvents("dump"), related: useEvents("related"), playlist: useEvents("playlist"), comment: useEvents("comment"), user: useEvents("user"), notification: useEvents("notification"), categories: useEvents("categories"), }, }), { wrapper }, ); const socket = FakeWebSocket.last; return { hook, socket, ws: () => hook.result.current.ws, events: () => hook.result.current.events, }; } beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }) ); afterEach(() => vi.useRealTimers()); describe("connection", () => { test("connects with the session token, and without one for guests", () => { const { socket } = setup(); expect(socket.url).toBe( `ws://localhost:8000/ws?token=${encodeURIComponent(me.token)}`, ); FakeWebSocket.reset(); localStorage.clear(); const guest = setup({ signedIn: false }); expect(guest.socket.url).toBe("ws://localhost:8000/ws"); }); test("connecting → connected on open", () => { const { socket, ws } = setup(); expect(ws().wsStatus).toBe("connecting"); socket.open(); expect(ws().wsStatus).toBe("connected"); expect(ws().wsErrorMessage).toBeNull(); }); test("a socket stuck connecting times out with a message", () => { const { ws } = setup(); act(() => vi.advanceTimersByTime(2_500)); expect(ws().wsStatus).toBe("disconnected"); expect(ws().wsErrorMessage).toMatch(/Can't connect/); }); test("reconnects with exponential backoff after a drop", () => { const { socket, ws } = setup(); socket.welcome(); socket.drop(); expect(ws().wsStatus).toBe("disconnected"); expect(ws().wsErrorMessage).toMatch(/temporarily disconnected/); expect(FakeWebSocket.instances).toHaveLength(1); act(() => vi.advanceTimersByTime(500)); expect(FakeWebSocket.instances).toHaveLength(2); FakeWebSocket.last.drop(); act(() => vi.advanceTimersByTime(999)); expect(FakeWebSocket.instances).toHaveLength(2); act(() => vi.advanceTimersByTime(1)); expect(FakeWebSocket.instances).toHaveLength(3); }); test("answers pings", () => { const { socket } = setup(); socket.open(); socket.receive({ type: "ping" }); expect(socket.messages).toEqual([{ type: "pong" }]); }); test("ignores garbage frames", () => { const { socket, ws } = setup(); socket.open(); socket.receiveRaw("not json"); socket.receiveRaw(JSON.stringify({ nope: 1 })); socket.receiveRaw("null"); expect(ws().wsStatus).toBe("connected"); }); test("welcome loads presence, votes, likes and unread counts", () => { const { socket, ws } = setup(); socket.welcome({ users: [{ userId: "u1", username: "a", hasAvatar: false }], myVotes: ["d1"], myCommentLikes: ["c1"], unreadNotificationCount: 4, unreadChatCount: 2, chatReadAt: "2026-01-01T00:00:00.000Z", }); expect(ws().onlineUsers).toHaveLength(1); expect(ws().myVotes.has("d1")).toBe(true); expect(ws().myLikes.has("c1")).toBe(true); expect(ws().unreadNotificationCount).toBe(4); expect(ws().unreadChatCount).toBe(2); expect(ws().chatReadAt).toEqual(new Date("2026-01-01T00:00:00.000Z")); expect(document.title).toMatch(/^\(6\)/); }); test("a reconnect bumps the epoch; the first connect doesn't", () => { const { socket, ws } = setup(); socket.welcome(); expect(ws().connectionEpoch).toBe(0); socket.drop(); act(() => vi.advanceTimersByTime(500)); FakeWebSocket.last.welcome(); expect(ws().connectionEpoch).toBe(1); }); test("signing out resets live state and reconnects as a guest", () => { const { socket, hook, ws } = setup(); socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 }); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "x" })) }); act(() => hook.result.current.auth.logout()); expect(ws().myVotes.size).toBe(0); expect(ws().recentDumps).toEqual([]); expect(ws().unreadNotificationCount).toBe(0); expect(socket.readyState).toBe(FakeWebSocket.CLOSED); expect(FakeWebSocket.last.url).toBe("ws://localhost:8000/ws"); }); test("unmounting closes the socket for good", () => { const { socket, hook } = setup(); socket.welcome(); hook.unmount(); expect(socket.readyState).toBe(FakeWebSocket.CLOSED); act(() => vi.advanceTimersByTime(60_000)); expect(FakeWebSocket.instances).toHaveLength(1); }); }); describe("votes", () => { test("cast is optimistic, sent, and confirmed by the ack", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().castVote("d1", 4)); expect(ws().myVotes.has("d1")).toBe(true); expect(ws().voteCounts.d1).toBe(5); expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" }); socket.receive({ type: "vote_ack", dumpId: "d1", action: "cast", voteCount: 7, }); expect(ws().voteCounts.d1).toBe(7); act(() => vi.advanceTimersByTime(10_000)); expect(ws().myVotes.has("d1")).toBe(true); }); test("an unconfirmed vote rolls back after the ack timeout", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().castVote("d1", 2)); act(() => vi.advanceTimersByTime(5_000)); expect(ws().myVotes.has("d1")).toBe(false); expect(ws().voteCounts.d1).toBe(2); }); test("a server error naming the dump rolls back at once", () => { const { socket, ws } = setup(); socket.welcome({ myVotes: ["d1"] }); act(() => ws().removeVote("d1", 3)); expect(ws().voteCounts.d1).toBe(2); socket.receive({ type: "error", message: "nope", dumpId: "d1", action: "remove", }); expect(ws().myVotes.has("d1")).toBe(true); expect(ws().voteCounts.d1).toBe(3); }); test("casting twice or removing an absent vote does nothing", () => { const { socket, ws } = setup(); socket.welcome({ myVotes: ["d1"] }); act(() => ws().castVote("d1", 1)); act(() => ws().removeVote("d2", 1)); expect(socket.messages).toEqual([]); }); test("remove never goes below zero", () => { const { socket, ws } = setup(); socket.welcome({ myVotes: ["d1"] }); act(() => ws().removeVote("d1", 0)); expect(ws().voteCounts.d1).toBe(0); }); test("offline, the vote is shown then rolled back", () => { const { ws } = setup(); act(() => ws().castVote("d1", 0)); expect(ws().voteCounts.d1).toBe(1); act(() => vi.advanceTimersByTime(5_000)); expect(ws().voteCounts.d1).toBe(0); }); test("votes_update from another tab of mine syncs myVotes", () => { const { socket, ws, events } = setup(); socket.welcome(); socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "cast", }); expect(ws().myVotes.has("d9")).toBe(true); expect(events().vote.at(-1)).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast", }); socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 2, voterId: "other", action: "cast", }); expect(ws().voteCounts.d9).toBe(2); socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "remove", }); expect(ws().myVotes.has("d9")).toBe(false); }); test("a welcome supersedes pending rollbacks", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().castVote("d1", 0)); socket.receive({ type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [], unreadNotificationCount: 0, unreadChatCount: 0, }); act(() => vi.advanceTimersByTime(10_000)); expect(ws().myVotes.has("d1")).toBe(true); }); test("a welcome says a vote never landed, and its count goes back", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().castVote("d1", 4)); expect(ws().voteCounts.d1).toBe(5); socket.welcome({ myVotes: [] }); expect(ws().myVotes.has("d1")).toBe(false); expect(ws().voteCounts.d1).toBe(4); }); test("clicks before an ack share one rollback, and the last ack decides", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().castVote("d1", 4)); act(() => ws().removeVote("d1")); act(() => ws().castVote("d1")); expect(ws().voteCounts.d1).toBe(5); // The first ack doesn't flip the button back while others are in flight. socket.receive({ type: "vote_ack", dumpId: "d1", action: "cast", voteCount: 5, }); socket.receive({ type: "vote_ack", dumpId: "d1", action: "remove", voteCount: 4, }); expect(ws().myVotes.has("d1")).toBe(true); // The last one is lost: the timer restores the state before the clicks. act(() => vi.advanceTimersByTime(5_000)); expect(ws().myVotes.has("d1")).toBe(false); expect(ws().voteCounts.d1).toBe(4); }); }); describe("comment likes", () => { test("like, ack, unlike, error rollback", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().castCommentLike("c1", 0)); expect(ws().likeCounts.c1).toBe(1); expect(socket.messages).toContainEqual({ type: "comment_like_cast", commentId: "c1", }); socket.receive({ type: "comment_like_ack", commentId: "c1", action: "cast", likeCount: 1, }); act(() => ws().removeCommentLike("c1")); expect(ws().likeCounts.c1).toBe(0); expect(socket.messages).toContainEqual({ type: "comment_like_remove", commentId: "c1", }); socket.receive({ type: "error", commentId: "c1" }); expect(ws().myLikes.has("c1")).toBe(true); expect(ws().likeCounts.c1).toBe(1); }); test("times out like votes do", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().castCommentLike("c1", 5)); act(() => vi.advanceTimersByTime(5_000)); expect(ws().likeCounts.c1).toBe(5); expect(ws().myLikes.has("c1")).toBe(false); }); test("comment_likes_update from my other tab", () => { const { socket, ws } = setup(); socket.welcome(); socket.receive({ type: "comment_likes_update", commentId: "c2", likeCount: 3, likerId: "user-me", action: "cast", }); expect(ws().myLikes.has("c2")).toBe(true); expect(ws().likeCounts.c2).toBe(3); }); }); describe("dumps, playlists, comments, users", () => { test("created dumps go on top, deduplicated, and undo a deletion", () => { const { socket, ws } = setup(); socket.welcome(); socket.receive({ type: "dump_deleted", dumpId: "a" }); expect(ws().deletedDumpIds.has("a")).toBe(true); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) }); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) }); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "again" })), }); expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]); expect(ws().recentDumps[0].title).toBe("again"); expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date); expect(ws().deletedDumpIds.has("a")).toBe(false); }); test("the live buffer is capped at 100", () => { const { socket, ws } = setup(); socket.welcome(); for (let i = 0; i < 105; i++) { socket.receive({ type: "dump_created", dump: wire(makeDump({ id: `d${i}` })), }); } expect(ws().recentDumps).toHaveLength(100); expect(ws().recentDumps[0].id).toBe("d104"); }); test("an update refreshes a live entry but doesn't make it an arrival", () => { const { socket, ws, events } = setup(); socket.welcome(); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "one" })), }); socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "a", title: "two" })), }); socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "old" })), }); expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([[ "a", "two", ]]); expect(events().dump.map((e) => [e.type, e.dump.id])).toEqual([ ["created", "a"], ["updated", "a"], ["updated", "old"], ]); }); test("deletion removes from the live buffer", () => { const { socket, ws } = setup(); socket.welcome(); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) }); socket.receive({ type: "dump_deleted", dumpId: "a" }); expect(ws().recentDumps).toEqual([]); }); test("changed links are passed on as a related event", () => { const { socket, events } = setup(); socket.welcome(); socket.receive({ type: "dump_related_changed", dumpIds: ["a", "b"] }); expect(events().related).toEqual([{ dumpIds: ["a", "b"] }]); }); test("injectDump adds my own new dump once", () => { const { ws } = setup(); const d = makeDump({ id: "mine" }); act(() => ws().injectDump(d)); act(() => ws().injectDump(d)); expect(ws().recentDumps).toEqual([d]); }); test("playlist events", () => { const { socket, ws, events } = setup(); socket.welcome(); const p = makePlaylist({ id: "p" }); socket.receive({ type: "playlist_created", playlist: wire(p) }); expect(events().playlist.at(-1)).toMatchObject({ type: "created", playlistId: "p", }); socket.receive({ type: "playlist_updated", playlist: wire(p) }); expect(events().playlist.at(-1)?.type).toBe("updated"); socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"], }); expect(events().playlist.at(-1)).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"], }); socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" }); expect(ws().deletedPlaylistIds.has("p")).toBe(true); expect(events().playlist.at(-1)).toEqual({ type: "deleted", playlistId: "p", userId: "u", }); // Going public again, after going private, is an arrival. socket.receive({ type: "playlist_created", playlist: wire(p) }); expect(ws().deletedPlaylistIds.has("p")).toBe(false); }); test("events arriving together all reach their subscribers", () => { const { socket, events } = setup(); socket.welcome(); // Two messages in one task, as from one network read: a single state // slot would keep only the last until the next render. act(() => { for (const id of ["n1", "n2"]) { socket.onmessage?.( new MessageEvent("message", { data: JSON.stringify({ type: "notification_created", notification: wire(makeNotification({ id })), }), }), ); } }); expect(events().notification.map((n) => n.id)).toEqual(["n1", "n2"]); }); test("comment events", () => { const { socket, events } = setup(); socket.welcome(); const c = makeComment({ id: "c", dumpId: "d" }); socket.receive({ type: "comment_created", comment: wire(c) }); expect(events().comment.at(-1)).toMatchObject({ type: "created", dumpId: "d", }); expect(events().comment.at(-1)?.comment?.createdAt).toBeInstanceOf(Date); socket.receive({ type: "comment_updated", comment: wire(c) }); expect(events().comment.at(-1)?.type).toBe("updated"); socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" }); expect(events().comment.at(-1)).toEqual({ type: "deleted", dumpId: "d", commentId: "c", }); }); test("user_updated overrides that user's avatar everywhere", () => { const { socket, hook, events } = setup(); socket.welcome(); const u = makeUser({ id: "u1", avatarMime: "image/png", updatedAt: new Date(5000), }); socket.receive({ type: "user_updated", user: wire(u) }); expect(hook.result.current.avatars.u1).toEqual({ hasAvatar: true, version: 5000, }); expect(events().user.at(-1)?.user.id).toBe("u1"); }); test("presence updates", () => { const { socket, ws } = setup(); socket.welcome(); socket.receive({ type: "presence_update", users: [{ userId: "x", username: "x", hasAvatar: false }], }); expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]); }); }); describe("notifications", () => { test("a new one bumps the count and is passed on", () => { const { socket, ws, events } = setup(); socket.welcome({ unreadNotificationCount: 1 }); socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })), }); expect(ws().unreadNotificationCount).toBe(2); expect(events().notification.map((n) => n.id)).toEqual(["n"]); act(() => ws().clearUnreadNotifications()); expect(ws().unreadNotificationCount).toBe(0); }); test("reading them in another tab clears the count here", () => { const { socket, ws } = setup(); socket.welcome({ unreadNotificationCount: 3 }); socket.receive({ type: "notifications_read" }); expect(ws().unreadNotificationCount).toBe(0); }); test("a category change is passed on", () => { const { socket, events } = setup(); socket.welcome(); socket.receive({ type: "categories_changed" }); expect(events().categories).toEqual([null]); }); }); describe("chat", () => { test("messages are buffered once, and count as unread unless mine", () => { const { socket, ws } = setup(); socket.welcome(); const m = makeChatMessage({ id: "m1", userId: "other" }); socket.receive({ type: "chat_message", message: wire(m) }); socket.receive({ type: "chat_message_updated", message: wire(m) }); socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: "m2", userId: "user-me" })), }); expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]); expect(ws().unreadChatCount).toBe(1); }); test("the buffer is capped at 200", () => { const { socket, ws } = setup(); socket.welcome(); for (let i = 0; i < 205; i++) { socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })), }); } expect(ws().chatMessages).toHaveLength(200); expect(ws().chatMessages[0].id).toBe("m5"); }); test("with the chat open and visible, arrivals aren't unread", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().setChatOpen(true)); expect(socket.messages).toContainEqual({ type: "chat_focus", open: true }); socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); expect(ws().unreadChatCount).toBe(0); }); test("an open chat in a hidden tab still counts unread", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().setChatOpen(true)); vi.spyOn(document, "hidden", "get").mockReturnValue(true); act(() => { document.dispatchEvent(new Event("visibilitychange")); }); expect(socket.messages.at(-1)).toEqual({ type: "chat_focus", open: false }); socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); expect(ws().unreadChatCount).toBe(1); }); test("sending shows a pending message, retired by its echo", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" }) ); const [pending] = ws().pendingChatMessages; expect(pending).toMatchObject({ body: "hello", status: "sending" }); expect(socket.messages).toContainEqual({ type: "chat_send", body: "hello", nonce: pending.nonce, replyToId: "r", }); socket.receive({ type: "chat_message", message: wire(makeChatMessage({ userId: "user-me", body: "hello" })), nonce: pending.nonce, }); expect(ws().pendingChatMessages).toEqual([]); act(() => vi.advanceTimersByTime(20_000)); expect(ws().pendingChatMessages).toEqual([]); }); test("blank messages aren't sent", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().sendChatMessage(" ")); expect(ws().pendingChatMessages).toEqual([]); expect(socket.messages).toEqual([]); }); test("offline sends fail at once", () => { const { ws } = setup(); act(() => ws().sendChatMessage("hi")); expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" }); expect(ws().pendingChatMessages[0].error).toMatch(/offline/); }); test("no echo in time marks the send failed; retry uses a fresh nonce", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().sendChatMessage("hi")); const first = ws().pendingChatMessages[0].nonce; act(() => vi.advanceTimersByTime(10_000)); expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" }); act(() => ws().retryChatMessage(first)); const retry = ws().pendingChatMessages[0]; expect(retry.status).toBe("sending"); expect(retry.nonce).not.toBe(first); expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength( 2, ); // The first attempt's late echo doesn't retire the retry. socket.receive({ type: "chat_message", message: wire(makeChatMessage({ userId: "user-me", body: "hi" })), nonce: first, }); expect(ws().pendingChatMessages).toHaveLength(1); }); test("a server rejection fails the pending send; discard drops it", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().sendChatMessage("hi")); const { nonce } = ws().pendingChatMessages[0]; socket.receive({ type: "error", scope: "chat", nonce, message: "Too long", }); expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed", error: "Too long", }); act(() => ws().discardChatMessage(nonce)); expect(ws().pendingChatMessages).toEqual([]); }); test("a chat error without a nonce is shown until dismissed", () => { const { socket, ws } = setup(); socket.welcome(); socket.receive({ type: "error", scope: "chat" }); expect(ws().chatError?.message).toBe("Something went wrong"); socket.receive({ type: "error", scope: "chat", message: "Not yours" }); expect(ws().chatError?.message).toBe("Not yours"); act(() => ws().dismissChatError()); expect(ws().chatError).toBeNull(); }); test("a dropped socket fails in-flight sends", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().sendChatMessage("hi")); socket.drop(); expect(ws().pendingChatMessages[0].error).toMatch(/Connection lost/); }); test("edits and deletes", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().editChatMessage("m1", " new ")); act(() => ws().editChatMessage("m1", " ")); act(() => ws().deleteChatMessage("m1")); expect(socket.messages).toEqual([ { type: "chat_edit", id: "m1", body: "new" }, { type: "chat_delete", id: "m1" }, ]); const m = makeChatMessage({ id: "m1", body: "old" }); socket.receive({ type: "chat_message", message: wire(m) }); socket.receive({ type: "chat_message_updated", message: wire({ ...m, body: "new" }), }); expect(ws().chatMessages[0].body).toBe("new"); socket.receive({ type: "chat_message_updated", message: wire(makeChatMessage({ id: "m2" })), }); expect(ws().chatMessages).toHaveLength(2); socket.receive({ type: "chat_message_deleted", id: "m1" }); expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]); expect(ws().deletedChatIds.has("m1")).toBe(true); }); test("typing: sent and received", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().setChatTyping(true)); expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]); socket.receive({ type: "chat_typing_update", users: [{ userId: "x", username: "x", hasAvatar: false }], }); expect(ws().typingUsers).toHaveLength(1); }); test("the read marker clears the badge at once and is throttled to the server", () => { const { socket, ws } = setup(); socket.welcome({ unreadChatCount: 3 }); act(() => ws().markChatRead(new Date(1_000_000))); expect(ws().unreadChatCount).toBe(0); act(() => vi.advanceTimersByTime(0)); expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength( 1, ); act(() => ws().markChatRead(new Date(2_000_000))); act(() => ws().markChatRead(new Date(3_000_000))); act(() => vi.advanceTimersByTime(999)); expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength( 1, ); act(() => vi.advanceTimersByTime(1)); const reads = socket.messages.filter((m) => m.type === "chat_read"); expect(reads).toHaveLength(2); expect(reads[1].at).toBe(new Date(3_000_000).toISOString()); // Older than what's been sent: ignored. act(() => ws().markChatRead(new Date(10))); act(() => vi.advanceTimersByTime(5_000)); expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength( 2, ); }); test("a marker set offline goes out with the next welcome", () => { const { socket, ws } = setup(); act(() => ws().markChatRead(new Date(5_000_000))); act(() => vi.advanceTimersByTime(2_000)); socket.welcome(); expect(socket.messages).toContainEqual({ type: "chat_read", at: new Date(5_000_000).toISOString(), }); }); test("chat_read_update from another tab moves the marker", () => { const { socket, ws } = setup(); socket.welcome({ unreadChatCount: 5 }); socket.receive({ type: "chat_read_update", readAt: "2026-05-01T00:00:00.000Z", unread: 1, }); expect(ws().unreadChatCount).toBe(1); expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z")); }); test("reconcile replaces stale copies, drops deleted ones, retires landed sends", () => { const { socket, ws } = setup(); socket.welcome(); const t0 = Date.now(); const at = (s: number) => new Date(t0 + s * 1000); const early = makeChatMessage({ id: "early", createdAt: at(-100) }); const edited = makeChatMessage({ id: "edited", body: "old", createdAt: at(-50), }); const gone = makeChatMessage({ id: "gone", createdAt: at(-40) }); for (const m of [early, edited, gone]) { socket.receive({ type: "chat_message", message: wire(m) }); } act(() => ws().sendChatMessage("did it land?")); socket.drop(); expect(ws().pendingChatMessages[0].status).toBe("failed"); act(() => ws().reconcileChat( [ { ...edited, body: "new" }, makeChatMessage({ id: "echo", userId: "user-me", body: "did it land?", createdAt: at(1), }), ], at(-60), at(60), ) ); expect(ws().chatMessages.map((m) => [m.id, m.body])).toEqual([ ["early", early.body], ["edited", "new"], ]); expect(ws().pendingChatMessages).toEqual([]); }); test("a reconnect with the chat closed drops the stale buffer", () => { const { socket, ws } = setup(); socket.welcome(); socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); socket.drop(); act(() => vi.advanceTimersByTime(500)); FakeWebSocket.last.welcome(); expect(ws().chatMessages).toEqual([]); }); test("a reconnect with the chat open keeps it, and re-announces focus", () => { const { socket, ws } = setup(); socket.welcome(); act(() => ws().setChatOpen(true)); socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); socket.drop(); act(() => vi.advanceTimersByTime(500)); FakeWebSocket.last.welcome(); expect(ws().chatMessages).toHaveLength(1); expect(FakeWebSocket.last.messages).toContainEqual({ type: "chat_focus", open: true, }); }); }); describe("split contexts", () => { test("chat, typing and presence leave the feed's context alone", () => { storeSession(me); defaultRoutes({ session: me }); const feeds: unknown[] = []; const chats: unknown[] = []; renderHook(() => { feeds.push(useWS()); chats.push(useWSChat()); }, { wrapper }); const socket = FakeWebSocket.last; socket.welcome(); const feed = feeds.at(-1); const chat = chats.at(-1); socket.receive({ type: "chat_typing_update", users: [{ userId: "x", username: "x", hasAvatar: false }], }); socket.receive({ type: "presence_update", users: [{ userId: "x", username: "x", hasAvatar: false }], }); socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); // The feed's value is the same object: its consumers had no reason to // re-render. The chat's moved on. expect(feeds.at(-1)).toBe(feed); expect(chats.at(-1)).not.toBe(chat); }); });