v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s

Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened.

Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it.

Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 14:35:41 +00:00
parent 25601edaf8
commit 54e86c4366
78 changed files with 10816 additions and 691 deletions

View File

@@ -0,0 +1,370 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useContext, useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
import type { AuthResponse } from "../../model.ts";
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
const embed = (n: number): PlayerItem => ({
kind: "embed",
embedUrl: `https://www.youtube.com/embed/v${n}`,
type: "youtube",
title: `Video ${n}`,
});
const file = (n: number): PlayerItem => ({
kind: "file",
fileUrl: `http://localhost:8000/api/files/f${n}?token=secret`,
mimeType: "audio/mpeg",
});
const stream = (i: number, over: Partial<PlayerItem> = {}) =>
({
kind: "stream",
type: "bandcamp",
streamUrl: `https://s/${i}`,
resolveUrl: "https://a.bandcamp.com/album/x",
resolveIndex: i,
resolvedAt: Date.now(),
title: `Track ${i}`,
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
...over,
}) as Extract<PlayerItem, { kind: "stream" }>;
const albumResponse = (indexes: number[]) => ({
sourceUrl: "https://a.bandcamp.com/album/x",
itemType: "album",
artist: "Band",
resolvedAt: Date.now(),
tracks: indexes.map((i) => ({
index: i,
title: `Track ${i}`,
streamUrl: `https://fresh/${i}`,
streamable: true,
capped: false,
})),
});
let setSession: (s: AuthResponse | null) => void = () => {};
function wrapperFor(initial: AuthResponse | null) {
return function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(initial);
setSession = setAuthResponse;
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
<PlayerProvider>{children}</PlayerProvider>
</AuthContext.Provider>
);
};
}
function setup(session: AuthResponse | null = null) {
const hook = renderHook(() => useContext(PlayerContext), {
wrapper: wrapperFor(session),
});
return { hook, p: () => hook.result.current };
}
const stored = () => JSON.parse(localStorage.getItem("player") ?? "null");
afterEach(() => vi.useRealTimers());
describe("queue", () => {
test("play() makes a one-item queue that autoplays", () => {
const { p } = setup();
act(() => p().play(embed(1)));
expect(p().current).toEqual(embed(1));
expect(p().queue).toHaveLength(1);
expect(p().autoplay).toBe(true);
expect(p().hasNext).toBe(false);
});
test("playQueue clamps the start; an empty queue is ignored", () => {
const { p } = setup();
act(() => p().playQueue([embed(1), embed(2)], 9));
expect(p().queueIndex).toBe(1);
act(() => p().playQueue([], 0));
expect(p().queue).toHaveLength(2);
act(() => p().playQueue([embed(1), embed(2)], -3));
expect(p().queueIndex).toBe(0);
});
test("next / previous / playAt", () => {
const { p } = setup();
act(() => p().playQueue([embed(1), embed(2), embed(3)]));
act(() => p().next());
expect(p().queueIndex).toBe(1);
expect(p().hasPrevious).toBe(true);
act(() => p().playAt(2));
expect(p().queueIndex).toBe(2);
act(() => p().next());
expect(p().queueIndex).toBe(2);
act(() => p().playAt(7));
expect(p().queueIndex).toBe(2);
act(() => p().previous());
expect(p().queueIndex).toBe(1);
});
test("previous restarts the track past three seconds", () => {
const { p } = setup();
const seek = vi.fn();
act(() => p().playQueue([embed(1), embed(2)], 1));
p().seekRef.current = seek;
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(10, 100));
act(() => p().previous());
expect(seek).toHaveBeenCalledWith(0);
expect(p().queueIndex).toBe(1);
expect(p().currentTime).toBe(0);
});
test("previous on the first track restarts it", () => {
const { p } = setup();
const seek = vi.fn();
act(() => p().playQueue([embed(1), embed(2)]));
p().seekRef.current = seek;
act(() => p().previous());
expect(seek).toHaveBeenCalledWith(0);
});
test("picking the playing row restarts it, and resumes it if paused", () => {
const { p } = setup();
const seek = vi.fn();
const toggle = vi.fn();
act(() => p().playQueue([embed(1), embed(2)]));
p().seekRef.current = seek;
p().toggleRef.current = toggle;
act(() => p().onPlayStateChange(true));
act(() => p().playAt(0));
expect(seek).toHaveBeenCalledWith(0);
expect(toggle).not.toHaveBeenCalled();
act(() => p().onPlayStateChange(false));
act(() => p().playAt(0));
expect(toggle).toHaveBeenCalledTimes(1);
});
test("the end of a track advances; the end of the queue stops", () => {
const { p } = setup();
act(() => p().playQueue([embed(1), embed(2)]));
act(() => p().onPlayStateChange(true));
act(() => p().onEnded());
expect(p().queueIndex).toBe(1);
act(() => p().onPlayStateChange(true));
act(() => p().onEnded());
expect(p().queueIndex).toBe(1);
expect(p().playing).toBe(false);
});
test("stop empties the player", () => {
const { p } = setup();
act(() => p().play(embed(1)));
act(() => p().stop());
expect(p().current).toBeNull();
expect(stored()).toBeNull();
});
test("stale time updates from the old media are ignored until the new one plays", () => {
const { p } = setup();
act(() => p().play(embed(1)));
act(() => p().onTimeUpdate(42, 100));
expect(p().currentTime).toBe(0);
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(5, 100));
expect(p().currentTime).toBe(5);
expect(p().duration).toBe(100);
});
test("seekTo and togglePlay reach the registered media", () => {
const { p } = setup();
const seek = vi.fn();
const toggle = vi.fn();
p().seekRef.current = seek;
p().toggleRef.current = toggle;
act(() => p().seekTo(12));
act(() => p().togglePlay());
expect(seek).toHaveBeenCalledWith(12);
expect(toggle).toHaveBeenCalled();
expect(p().currentTime).toBe(12);
});
});
describe("persistence", () => {
test("the queue is saved with its owner, and the time on pagehide", () => {
const session = makeSession({ id: "me" });
const { p } = setup(session);
act(() => p().playQueue([file(1), file(2)], 1));
expect(stored()).toMatchObject({ v: 2, index: 1, owner: "me", time: 0 });
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(33, 100));
globalThis.dispatchEvent(new Event("pagehide"));
expect(stored().time).toBe(33);
});
test("a saved session comes back paused at its offset", () => {
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [embed(1), embed(2)], index: 1, time: 17, owner: null }),
);
const { p } = setup();
expect(p().queueIndex).toBe(1);
expect(p().startTime).toBe(17);
expect(p().autoplay).toBe(false);
});
test("a v1 single-item session is still read", () => {
localStorage.setItem("player", JSON.stringify({ item: embed(3), time: 4 }));
const { p } = setup();
expect(p().current).toEqual(embed(3));
expect(p().startTime).toBe(4);
});
test("another account's queue isn't restored", () => {
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [file(1)], index: 0, time: 0, owner: "someone-else" }),
);
const { p } = setup(makeSession({ id: "me" }));
expect(p().current).toBeNull();
});
test("an ownerless session holding file URLs isn't restored; one of embeds is", () => {
localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 }));
expect(setup().p().current).toBeNull();
localStorage.setItem("player", JSON.stringify({ queue: [embed(1)], index: 0, time: 0 }));
expect(setup().p().current).toEqual(embed(1));
});
test("junk in storage is ignored", () => {
localStorage.setItem("player", "{nope");
expect(setup().p().current).toBeNull();
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [{ kind: "weird" }], index: 0, owner: null }),
);
expect(setup().p().current).toBeNull();
});
test("signing out stops a signed-in listener's queue", () => {
const { p } = setup(makeSession({ id: "me" }));
act(() => p().play(file(1)));
act(() => setSession(null));
expect(p().current).toBeNull();
});
test("signing in keeps a guest's queue", () => {
const { p } = setup(null);
act(() => p().play(embed(1)));
act(() => setSession(makeSession({ id: "me" })));
expect(p().current).toEqual(embed(1));
expect(stored().owner).toBe("me");
});
});
describe("expiring streams", () => {
test("a stream error re-resolves and resumes on the same track", async () => {
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1, 2]));
const { p } = setup();
act(() => p().playQueue([stream(0), stream(1), stream(2)], 1));
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(40, 200));
act(() => p().onError());
expect(p().resolving).toBe(true);
await waitFor(() => expect(p().resolving).toBe(false));
expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" });
expect(p().startTime).toBe(40);
expect(p().autoplay).toBe(true);
});
test("a track gone from the release restarts from the first one", async () => {
route("GET /api/bandcamp/tracks", () => albumResponse([0, 2]));
const { p } = setup();
act(() => p().playQueue([stream(0), stream(1)], 1));
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(40, 200));
act(() => p().onError());
await waitFor(() => expect(p().resolving).toBe(false));
expect(p().queueIndex).toBe(0);
expect(p().startTime).toBe(0);
});
test("a resolve that fails falls back to the embed", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502));
const { p } = setup();
act(() => p().play(stream(0)));
act(() => p().onError());
await waitFor(() => expect(p().current?.kind).toBe("embed"));
expect(p().current).toMatchObject({
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
title: "Track 0",
});
});
test("no embed to fall back to: the player stops", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502));
const { p } = setup();
act(() => p().play(stream(0, { embedUrl: undefined })));
act(() => p().onError());
await waitFor(() => expect(p().current).toBeNull());
});
test("a track that dies again right after a re-resolve is skipped", async () => {
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
const { p } = setup();
act(() => p().playQueue([stream(0), stream(1)], 0));
act(() => p().onError());
await waitFor(() => expect(p().resolving).toBe(false));
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
act(() => p().onError());
expect(p().queueIndex).toBe(1);
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
});
test("a resolve that lands after the user moved on is dropped", async () => {
const pending = deferred();
route("GET /api/bandcamp/tracks", () => pending.promise);
const { p } = setup();
act(() => p().play(stream(0)));
act(() => p().onError());
act(() => p().play(embed(9)));
await act(async () =>
pending.resolve(
new Response(JSON.stringify({ success: true, data: albumResponse([0]) })),
)
);
expect(p().current).toEqual(embed(9));
expect(p().resolving).toBe(false);
});
test("errors on non-stream items are ignored", () => {
const { p } = setup();
act(() => p().play(embed(1)));
act(() => p().onError());
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(0);
});
test("a stale restored stream is refreshed up front, without autoplay", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(10 * STREAM_STALE_MS);
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
localStorage.setItem(
"player",
JSON.stringify({
v: 2,
queue: [stream(0, { resolvedAt: 0 }), stream(1, { resolvedAt: 0 })],
index: 1,
time: 25,
owner: null,
}),
);
const { p } = setup();
await waitFor(() => expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" }));
expect(p().autoplay).toBe(false);
expect(p().startTime).toBe(25);
});
});
beforeEach(() => {
setSession = () => {};
});

View File

@@ -0,0 +1,648 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { useContext } from "react";
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
import { WSProvider } from "../../contexts/WSProvider.tsx";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { useWS } from "../../hooks/useWS.ts";
import { useAuth } from "../../hooks/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>
);
}
function setup({ signedIn = true } = {}) {
if (signedIn) storeSession(me);
defaultRoutes({ session: signedIn ? me : null });
const hook = renderHook(
() => ({
ws: useWS(),
auth: useAuth(),
avatars: useContext(AvatarOverrideContext),
}),
{ wrapper },
);
const socket = FakeWebSocket.last;
return { hook, socket, ws: () => hook.result.current.ws };
}
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("force_logout signs out", () => {
const { socket, hook } = setup();
socket.welcome();
socket.receive({ type: "force_logout" });
expect(hook.result.current.auth.user).toBeNull();
});
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 } = 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(ws().lastVoteEvent).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);
});
});
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);
expect(ws().lastLikeEvent?.commentId).toBe("c2");
});
});
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 } = 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(ws().lastDumpEvent?.id).toBe("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("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 } = setup();
socket.welcome();
const p = makePlaylist({ id: "p" });
socket.receive({ type: "playlist_created", playlist: wire(p) });
expect(ws().lastPlaylistEvent).toMatchObject({ type: "created", playlistId: "p" });
socket.receive({ type: "playlist_updated", playlist: wire(p) });
expect(ws().lastPlaylistEvent?.type).toBe("updated");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"] });
expect(ws().lastPlaylistEvent).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(ws().lastPlaylistEvent).toEqual({ type: "deleted", playlistId: "p", userId: "u" });
});
test("comment events", () => {
const { socket, ws } = setup();
socket.welcome();
const c = makeComment({ id: "c", dumpId: "d" });
socket.receive({ type: "comment_created", comment: wire(c) });
expect(ws().lastCommentEvent).toMatchObject({ type: "created", dumpId: "d" });
expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date);
socket.receive({ type: "comment_updated", comment: wire(c) });
expect(ws().lastCommentEvent?.type).toBe("updated");
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
expect(ws().lastCommentEvent).toEqual({ type: "deleted", dumpId: "d", commentId: "c" });
});
test("user_updated overrides that user's avatar everywhere", () => {
const { socket, hook } = 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(hook.result.current.ws.lastUserEvent?.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 remembered", () => {
const { socket, ws } = setup();
socket.welcome({ unreadNotificationCount: 1 });
socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })) });
expect(ws().unreadNotificationCount).toBe(2);
expect(ws().lastNotification?.id).toBe("n");
act(() => ws().clearUnreadNotifications());
expect(ws().unreadNotificationCount).toBe(0);
});
});
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 });
});
});

View File

@@ -0,0 +1,313 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router";
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
import { FollowProvider } from "../../contexts/FollowProvider.tsx";
import { CategoriesProvider } from "../../contexts/CategoriesProvider.tsx";
import { ThemeProvider } from "../../contexts/ThemeProvider.tsx";
import { ChatProvider } from "../../contexts/ChatProvider.tsx";
import { useAuth } from "../../hooks/useAuth.ts";
import { useFollows } from "../../hooks/useFollows.ts";
import { useCategories } from "../../hooks/useCategories.ts";
import { useTheme } from "../../hooks/useTheme.ts";
import { useChat } from "../../hooks/useChat.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, deferred, fail, ok, route } from "../fetch.ts";
import {
makeCategory,
makeSession,
makeToken,
storeSession,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
const authWrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
describe("AuthProvider", () => {
test("starts from the stored session", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user?.username).toBe("ann");
});
test("a token the server rejects is dropped", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => new Response(null, { status: 401 }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user).toBeNull());
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
`Bearer ${session.token}`,
);
});
test("a role changed on the server is picked up", async () => {
const session = makeSession({ role: "user" });
storeSession(session);
route("GET /api/users/me", () => wire({ ...session.user, role: "moderator" }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
"moderator",
);
});
test("offline doesn't sign anyone out", async () => {
storeSession(makeSession());
route("GET /api/users/me", () => new TypeError("offline"));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
expect(result.current.user).not.toBeNull();
});
test("revalidates when the tab regains focus", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
act(() => {
globalThis.dispatchEvent(new Event("focus"));
});
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
});
test("a session without a media token fetches one", async () => {
const session = { ...makeSession(), mediaToken: undefined };
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
"m-1",
);
});
test("follows sign-in and sign-out from other tabs", async () => {
route("GET /api/users/me", () => ok(null));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user).toBeNull();
const session = makeSession({ username: "elsewhere" });
storeSession(session);
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: AUTH_STORAGE_KEY }));
});
expect(result.current.user?.username).toBe("elsewhere");
localStorage.removeItem(AUTH_STORAGE_KEY);
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: "unrelated" }));
});
expect(result.current.user).not.toBeNull();
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: null }));
});
expect(result.current.user).toBeNull();
});
test("a 401 for an old token doesn't sign out a newer session", async () => {
const first = makeSession();
storeSession(first);
const pending = deferred();
route("GET /api/users/me", () => pending.promise, { once: true });
route("GET /api/users/me", () => wire(first.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
const second = { ...first, token: makeToken() };
act(() => result.current.login(second));
await act(async () => pending.resolve(new Response(null, { status: 401 })));
expect(result.current.token).toBe(second.token);
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
second.token,
);
});
});
describe("FollowProvider", () => {
function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/follows/status", () => status);
return renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
}
test("loads what the user follows", async () => {
const { result } = setup();
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.followedUserIds.has("u1")).toBe(true);
expect(result.current.followedPlaylistIds.has("p1")).toBe(true);
});
test("guests follow nobody and never ask", async () => {
const { result } = renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
await act(async () => {});
expect(result.current.isLoaded).toBe(false);
expect(calls("GET /api/follows/status")).toHaveLength(0);
});
test("follow and unfollow are optimistic and roll back on failure", async () => {
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
route("POST /api/follows/users/:id", () => ({}));
await act(() => result.current.followUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("DELETE /api/follows/users/:id", () => fail("no", 500));
await act(() => result.current.unfollowUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
await act(() => result.current.followPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
route("POST /api/follows/playlists/:id", () => ({}));
await act(() => result.current.followPlaylist("p2"));
route("DELETE /api/follows/playlists/:id", () => ({}));
await act(() => result.current.unfollowPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
"/api/follows/playlists/p2",
);
});
});
describe("CategoriesProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<CategoriesProvider>{children}</CategoriesProvider>
);
test("loads and indexes categories", async () => {
const music = makeCategory({ id: "c1", slug: "music" });
route("GET /api/categories", () => wire([music]));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.byId.get("c1")?.slug).toBe("music");
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
});
test("refresh refetches", async () => {
let list = [makeCategory({ id: "a" })];
route("GET /api/categories", () => wire(list));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.categories).toHaveLength(1));
list = [...list, makeCategory({ id: "b" })];
await act(() => result.current.refresh());
expect(result.current.categories).toHaveLength(2);
});
test("a failure still ends loading", async () => {
route("GET /api/categories", () => fail("x", 500));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.categories).toEqual([]);
});
});
describe("ThemeProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const root = document.documentElement;
test("smooth and auto by default, with no attributes", () => {
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("smooth");
expect(result.current.colorScheme).toBe("auto");
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("applies and persists a choice", () => {
const { result } = renderHook(useTheme, { wrapper });
act(() => result.current.setStyle("brutalist"));
act(() => result.current.setColorScheme("dark"));
expect(root.getAttribute("data-style")).toBe("brutalist");
expect(root.getAttribute("data-color-scheme")).toBe("dark");
expect(localStorage.getItem("style")).toBe("brutalist");
expect(localStorage.getItem("color-scheme")).toBe("dark");
act(() => result.current.setStyle("smooth"));
act(() => result.current.setColorScheme("auto"));
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("restores the stored theme, migrating the legacy name", () => {
localStorage.setItem("style", "nyt");
localStorage.setItem("color-scheme", "light");
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("nyt");
expect(result.current.colorScheme).toBe("light");
localStorage.setItem("style", "default");
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe("smooth");
});
});
describe("ChatProvider", () => {
test("opens only for signed-in users", async () => {
const guest = renderHook(useChat, {
wrapper: ({ children }) => (
<AuthProvider>
<MemoryRouter>
<ChatProvider>{children}</ChatProvider>
</MemoryRouter>
</AuthProvider>
),
});
act(() => guest.result.current.openChat());
expect(guest.result.current.isChatOpen).toBe(false);
});
test("?chat=open deep-links into it, and closing strips the param", async () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
expect(chat.isChatOpen).toBe(true);
await screen.findByRole("dialog", { name: "Chat" });
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
expect(currentPath()).toBe("/somewhere?x=1");
});
test("openChat / closeChat", () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session });
act(() => chat.openChat());
expect(chat.isChatOpen).toBe(true);
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
});
});