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
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:
370
src/test/contexts/PlayerProvider.test.tsx
Normal file
370
src/test/contexts/PlayerProvider.test.tsx
Normal 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 = () => {};
|
||||
});
|
||||
648
src/test/contexts/WSProvider.test.tsx
Normal file
648
src/test/contexts/WSProvider.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
313
src/test/contexts/providers.test.tsx
Normal file
313
src/test/contexts/providers.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user