941 lines
31 KiB
TypeScript
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);
|
|
});
|
|
});
|