Files
gerbeur/src/test/contexts/ws/WSProvider.test.tsx

941 lines
31 KiB
TypeScript

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 (
<AuthProvider>
<WSProvider>{children}</WSProvider>
</AuthProvider>
);
}
/** Every event of `kind` the provider emitted, oldest first. */
function useEvents<K extends keyof WSEvents>(kind: K): WSEvents[K][] {
// One array for the hook's life, which the subscription fills.
const [received] = useState<WSEvents[K][]>(() => []);
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);
});
});