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,304 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatModal } from "../../components/ChatModal.tsx";
import type { AuthResponse, ChatMessage } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeChatMessage, makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const me = makeSession({ id: "me", username: "me" });
async function setup(
history: ChatMessage[] = [],
{ session = me, welcome = {} as Record<string, unknown> } = {},
) {
route("GET /api/chat", () => wire(history));
const onClose = vi.fn();
const user = userEvent.setup();
renderApp(<ChatModal onClose={onClose} />, { session });
const socket = FakeWebSocket.last;
socket.welcome(welcome);
await screen.findByRole("dialog", { name: "Chat" });
await waitFor(() =>
expect(screen.queryByText("Loading messages…")).not.toBeInTheDocument()
);
const composer = screen.getByPlaceholderText("Type a message…");
return { user, socket, onClose, composer };
}
const sent = (socket: FakeWebSocket, type: string) =>
socket.messages.filter((m) => m.type === type);
describe("ChatModal: history", () => {
test("shows the latest messages under a day separator, grouping runs", async () => {
const t0 = Date.now() - 60_000;
await setup([
makeChatMessage({ id: "1", userId: "ann", authorUsername: "ann", body: "one", createdAt: new Date(t0) }),
makeChatMessage({ id: "2", userId: "ann", authorUsername: "ann", body: "two", createdAt: new Date(t0 + 1000) }),
makeChatMessage({ id: "3", userId: "bob", authorUsername: "bob", body: "three", createdAt: new Date(t0 + 2000) }),
]);
expect(screen.getByText("Today")).toBeInTheDocument();
// "two" continues ann's run: no second author link.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
expect(screen.getByText("two").closest(".chat-message")).toHaveClass("chat-message--grouped");
expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50");
});
test("empty and failed states", async () => {
route("GET /api/chat", () => fail("down", 500), { once: true });
route("GET /api/chat", () => []);
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await screen.findByText("Couldn't load the chat.");
await user.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByText("No messages yet. Say hello!");
});
test("a full page offers older messages", async () => {
const page = Array.from({ length: 50 }, (_, i) =>
makeChatMessage({ id: `m${i + 10}`, body: `msg ${i + 10}`, createdAt: new Date(Date.now() - (50 - i) * 1000) })
);
route("GET /api/chat", (req) =>
req.url.searchParams.has("before")
? wire([makeChatMessage({ id: "m0", body: "the oldest", createdAt: new Date(Date.now() - 999_000) })])
: wire(page));
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load older messages" }));
await screen.findByText("the oldest");
const req = calls("GET /api/chat").find((r) => r.url.searchParams.has("before"))!;
expect(req.url.searchParams.get("beforeId")).toBe("m10");
expect(screen.queryByRole("button", { name: "Load older messages" })).not.toBeInTheDocument();
});
test("the 'new' divider sits before the first unread message from someone else", async () => {
const readAt = new Date(Date.now() - 30_000);
await setup([
makeChatMessage({ id: "old", body: "seen", createdAt: new Date(Date.now() - 60_000) }),
makeChatMessage({ id: "new1", body: "unseen", createdAt: new Date(Date.now() - 10_000) }),
], { welcome: { chatReadAt: readAt.toISOString() } });
const divider = document.querySelector(".chat-new-sep")!;
expect(divider).toHaveTextContent("New");
expect(divider.nextElementSibling).toHaveTextContent("unseen");
});
test("reading at the bottom moves the read marker", async () => {
const { socket } = await setup([makeChatMessage({ body: "hi" })]);
await waitFor(() => expect(sent(socket, "chat_read")).toHaveLength(1));
});
test("tells the server the chat is open, and closed", async () => {
const { socket } = await setup();
expect(sent(socket, "chat_focus")).toContainEqual({ type: "chat_focus", open: true });
});
});
describe("ChatModal: sending", () => {
test("Enter sends; the message shows as sending until its echo", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "hello there{Enter}");
expect(composer).toHaveValue("");
const [send] = sent(socket, "chat_send");
expect(send).toMatchObject({ body: "hello there" });
expect(screen.getByText("Sending…")).toBeInTheDocument();
socket.receive({
type: "chat_message",
nonce: send.nonce,
message: wire(makeChatMessage({ id: "echo", userId: "me", authorUsername: "me", body: "hello there", createdAt: new Date() })),
});
expect(screen.queryByText("Sending…")).not.toBeInTheDocument();
expect(screen.getByText("hello there")).toBeInTheDocument();
});
test("Shift+Enter is a new line; blank and over-long messages don't send", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "line one{Shift>}{Enter}{/Shift}line two");
expect(composer).toHaveValue("line one\nline two");
await user.clear(composer);
await user.type(composer, " {Enter}");
expect(sent(socket, "chat_send")).toHaveLength(0);
await user.clear(composer);
await user.click(composer);
await user.paste("x".repeat(2001));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText("2001 / 2000")).toBeInTheDocument();
});
test("a failed send can be retried, taken back or dropped", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "oops{Enter}");
const [send] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: send.nonce, message: "Slow down" });
const failed = screen.getByText("Slow down").closest(".chat-message")! as HTMLElement;
await user.click(within(failed).getByRole("button", { name: "Retry" }));
expect(sent(socket, "chat_send")).toHaveLength(2);
const [, retry] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: retry.nonce, message: "Still no" });
await user.type(composer, "draft");
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(composer).toHaveValue("draft\n\noops");
expect(screen.queryByText("Still no")).not.toBeInTheDocument();
});
test("typing is announced, and its end too", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true });
try {
const { socket, composer } = await setup();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(composer, "abc");
expect(sent(socket, "chat_typing")).toEqual([{ type: "chat_typing", typing: true }]);
act(() => vi.advanceTimersByTime(3_600));
expect(sent(socket, "chat_typing").at(-1)).toEqual({ type: "chat_typing", typing: false });
} finally {
vi.useRealTimers();
}
});
test("the draft and reply outlive the chat", async () => {
const first = await setup([makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" })]);
await first.user.type(first.composer, "half a thought");
const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!);
expect(stored.draft).toBe("half a thought");
});
});
describe("ChatModal: replies, edits, deletes", () => {
const theirs = () =>
makeChatMessage({ id: "t1", userId: "ann", authorUsername: "ann", body: "ask me **anything**", createdAt: new Date(Date.now() - 20_000) });
const mine = () =>
makeChatMessage({ id: "m1", userId: "me", authorUsername: "me", body: "my words", createdAt: new Date(Date.now() - 10_000) });
test("replying: the bar, the reference, and Escape cancelling just the reply", async () => {
const { user, socket, composer, onClose } = await setup([theirs()]);
const msg = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
await user.click(within(msg).getByRole("button", { name: "Reply" }));
expect(screen.getByText("Replying to")).toBeInTheDocument();
expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent("ask me anything");
await user.keyboard("{Escape}");
expect(screen.queryByText("Replying to")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(msg).getByRole("button", { name: "Reply" }));
await user.type(composer, "sure{Enter}");
expect(sent(socket, "chat_send")[0]).toMatchObject({ body: "sure", replyToId: "t1" });
});
test("only your own messages can be edited and deleted", async () => {
await setup([theirs(), mine()]);
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
expect(within(t).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("moderators can delete anyone's", async () => {
await setup([theirs()], { session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse });
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
expect(within(t).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("Up in an empty composer edits your last message; Enter saves", async () => {
const { user, socket, composer } = await setup([theirs(), mine()]);
await user.click(composer);
await user.keyboard("{ArrowUp}");
const box = await screen.findByDisplayValue("my words");
await user.clear(box);
await user.type(box, "better words{Enter}");
expect(sent(socket, "chat_edit")).toEqual([{ type: "chat_edit", id: "m1", body: "better words" }]);
expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument();
});
test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(sent(socket, "chat_edit")).toHaveLength(0);
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
test("deleting asks first; Escape on the question keeps the chat open", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Delete" }));
expect(screen.getByRole("alertdialog")).toHaveTextContent("Delete this message?");
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(m).getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
expect(sent(socket, "chat_delete")).toEqual([{ type: "chat_delete", id: "m1" }]);
socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(screen.queryByText("my words")).not.toBeInTheDocument();
});
test("a reply to a deleted message says so", async () => {
const { socket } = await setup([
theirs(),
makeChatMessage({ id: "r", body: "a reply", replyToId: "t1", replyToAuthor: "ann", replyToBody: "ask me" }),
]);
socket.receive({ type: "chat_message_deleted", id: "t1" });
expect(screen.getByText("deleted message").closest("button")).toBeDisabled();
});
});
describe("ChatModal: live", () => {
test("new messages arrive, and a mention of me stands out", async () => {
const { socket } = await setup();
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: "x", body: "hey @me look", createdAt: new Date() })),
});
expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass("chat-message--mention");
});
test("an emoji-only message is shown large", async () => {
await setup([makeChatMessage({ body: "🎉🎉" })]);
expect(document.querySelector(".chat-message-text--jumbo")).toHaveTextContent("🎉🎉");
});
test("who's typing", async () => {
const { socket } = await setup();
socket.receive({ type: "chat_typing_update", users: [{ userId: "ann", username: "ann", hasAvatar: false }] });
expect(screen.getByText("ann is typing…")).toBeInTheDocument();
socket.receive({
type: "chat_typing_update",
users: [
{ userId: "ann", username: "ann", hasAvatar: false },
{ userId: "bob", username: "bob", hasAvatar: false },
],
});
expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument();
socket.receive({ type: "chat_typing_update", users: [{ userId: "me", username: "me", hasAvatar: false }] });
expect(screen.queryByText(/typing…/)).not.toBeInTheDocument();
});
test("a failed edit or delete is shown until dismissed", async () => {
const { user, socket } = await setup();
socket.receive({ type: "error", scope: "chat", message: "Not allowed" });
expect(screen.getByRole("alert")).toHaveTextContent("Not allowed");
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Not allowed")).not.toBeInTheDocument();
});
test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => {
const old = makeChatMessage({ id: "o", body: "kept", createdAt: new Date(Date.now() - 50_000) });
const edited = makeChatMessage({ id: "e", body: "before", createdAt: new Date(Date.now() - 40_000) });
const { socket } = await setup([old, edited]);
route("GET /api/chat", () => wire([old, { ...edited, body: "after" }]));
socket.drop();
expect(screen.getByText("Disconnected — reconnecting…")).toBeInTheDocument();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("after");
const resync = calls("GET /api/chat").at(-1)!;
expect(resync.url.searchParams.get("after")).toBe(old.createdAt.toISOString());
});
});

View File

@@ -0,0 +1,200 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { CommentThread } from "../../components/CommentThread.tsx";
import type { AuthResponse, Comment } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeComment, makeSession, wire } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
const me = makeSession({ id: "me", username: "me" });
const mod = makeSession({ id: "mod", username: "mod", role: "moderator" });
function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
const user = userEvent.setup();
function Harness() {
const [comments, setComments] = useState(initial);
return (
<CommentThread
dumpId="d1"
comments={comments}
currentUser={session?.user ?? null}
token={session?.token ?? null}
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
onCommentDeleted={(id) =>
setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true } : c))}
onCommentUpdated={(c) =>
setComments((prev) => prev.map((x) => x.id === c.id ? c : x))}
/>
);
}
renderWithContexts(<Harness />, { session, ws });
return user;
}
const created = (over: Partial<Comment>) =>
(req: { body: unknown }) =>
wire(makeComment({ dumpId: "d1", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, ...over }));
describe("CommentThread", () => {
test("counts visible comments and nests replies", () => {
setup([
makeComment({ id: "a", body: "top" }),
makeComment({ id: "b", parentId: "a", body: "reply" }),
makeComment({ id: "c", deleted: true, body: "" }),
]);
expect(screen.getByRole("heading", { name: "2 comments" })).toBeInTheDocument();
const top = screen.getByText("top").closest("li")!;
expect(within(top).getByText("reply")).toBeInTheDocument();
expect(screen.getByText("[deleted]")).toBeInTheDocument();
});
test("guests read but can't write, reply or like", () => {
setup([makeComment({ body: "hello" })], null);
expect(screen.queryByPlaceholderText("Add a comment…")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reply" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
});
test("posting a comment", async () => {
route("POST /api/dumps/:id/comments", created({ id: "new" }));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
expect(screen.getByRole("button", { name: "Post comment" })).toBeDisabled();
await user.type(box, "First!");
await user.click(screen.getByRole("button", { name: "Post comment" }));
await screen.findByText("First!", { selector: ".comment-body *" });
expect(box).toHaveValue("");
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "First!" });
expect(screen.getByRole("heading", { name: "1 comment" })).toBeInTheDocument();
});
test("Ctrl+Enter posts too; blank text doesn't", async () => {
route("POST /api/dumps/:id/comments", created({}));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
await user.type(box, " ");
await user.keyboard("{Control>}{Enter}{/Control}");
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0);
await user.type(box, "ok");
await user.keyboard("{Control>}{Enter}{/Control}");
await waitFor(() => expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1));
});
test("a failed post keeps the text and says why", async () => {
route("POST /api/dumps/:id/comments", () => fail("Comment too long", 400));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
await user.type(box, "text");
await user.click(screen.getByRole("button", { name: "Post comment" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to post comment");
expect(alert).toHaveTextContent("Comment too long");
expect(box).toHaveValue("text");
});
test("the top-level Cancel only shows with text, and clears it", async () => {
const user = setup([]);
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Add a comment…"), "draft");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue("");
});
test("replying", async () => {
route("POST /api/dumps/:id/comments", created({ id: "r", parentId: "a" }));
const user = setup([makeComment({ id: "a", body: "parent" })]);
await user.click(screen.getByRole("button", { name: "Reply" }));
const box = screen.getByPlaceholderText("Write a reply…");
await waitFor(() => expect(box).toHaveFocus());
await user.type(box, "child");
await user.click(screen.getByRole("button", { name: "Post reply" }));
await waitFor(() => expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument());
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "child", parentId: "a" });
const parent = screen.getByText("parent").closest("li")!;
expect(within(parent).getByText("child")).toBeInTheDocument();
});
test("Escape backs out of a reply", async () => {
const user = setup([makeComment({ id: "a" })]);
await user.click(screen.getByRole("button", { name: "Reply" }));
await user.type(screen.getByPlaceholderText("Write a reply…"), "nah{Escape}");
expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument();
});
test("editing your own comment", async () => {
route("PATCH /api/comments/:id", (req) =>
wire(makeComment({ id: "a", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, updatedAt: new Date() })));
const user = setup([makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
const box = screen.getByDisplayValue("old");
await user.clear(box);
await user.type(box, "new");
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("new");
expect(screen.getByText(/^edited/)).toBeInTheDocument();
});
test("saving an edit unchanged doesn't stamp it edited", async () => {
const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument());
expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
});
test("only the author or a moderator can edit and delete", () => {
const comments = [
makeComment({ id: "mine", userId: "me", body: "mine" }),
makeComment({ id: "theirs", userId: "them", body: "theirs" }),
];
setup(comments);
const theirs = screen.getByText("theirs").closest("li")!;
expect(within(theirs).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(theirs).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
const mine = screen.getByText("mine").closest("li")!;
expect(within(mine).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("a moderator can delete anyone's", () => {
setup([makeComment({ userId: "them", body: "theirs" })], mod);
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("deleting asks first", async () => {
route("DELETE /api/comments/:id", () => new Response(null, { status: 204 }));
const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]);
await user.click(screen.getByRole("button", { name: "Delete" }));
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveTextContent("Delete this comment?");
await user.click(within(dialog).getByRole("button", { name: "Delete" }));
await screen.findByText("[deleted]");
});
test("a failed delete says so", async () => {
route("DELETE /api/comments/:id", () => fail("Forbidden", 403));
const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]);
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't be deleted/i);
expect(screen.getByText("stays")).toBeInTheDocument();
});
test("likes come from the live counts", async () => {
const castCommentLike = vi.fn();
const user = setup([makeComment({ id: "a", likeCount: 1 })], me, {
likeCounts: { a: 5 },
castCommentLike,
});
expect(screen.getByText("5")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Like" }));
expect(castCommentLike).toHaveBeenCalledWith("a", 5);
});
test("the timestamp links to the comment", () => {
setup([makeComment({ id: "a" })]);
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute("href", "/dumps/d1#comment-a");
});
});

View File

@@ -0,0 +1,336 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DumpCreateModal } from "../../components/DumpCreateModal.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
const session = makeSession({ username: "me" });
const categories = [makeCategory({ id: "c1", name: "Music" })];
function setup(props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {}) {
const onClose = vi.fn();
const injectDump = vi.fn();
const user = userEvent.setup();
renderWithContexts(<DumpCreateModal onClose={onClose} {...props} />, {
session,
categories,
ws: { injectDump },
});
return { user, onClose, injectDump };
}
function previewRoutes({ title = "Page title", reached = true, dupes = [] as unknown[] } = {}) {
route("GET /api/preview", (req) => ({
reached,
richContent: reached
? { type: "link", url: req.url.searchParams.get("url"), title, siteName: "Site" }
: { type: "link", url: req.url.searchParams.get("url") },
}));
route("GET /api/dumps/by-url", () => dupes);
}
const nextBtn = () => screen.getByRole("button", { name: "Next →" });
const linkBox = () => screen.getByPlaceholderText("Paste a link…");
describe("DumpCreateModal: a link", () => {
test("Next stays disabled until there is a link or a file", async () => {
const { user } = setup();
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
expect(nextBtn()).toBeDisabled();
await user.type(linkBox(), "not a url at all");
expect(nextBtn()).toBeDisabled();
});
test("previews the page and suggests its title", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "example.com/a");
expect(await screen.findByText("Fetching preview…")).toBeInTheDocument();
// The suggestion is written in an effect just after the field appears.
const title = await screen.findByLabelText("Title");
await waitFor(() => expect(title).toHaveValue("Page title"));
expect(title).toBeDisabled();
const [req] = calls("GET /api/preview");
expect(req.url.searchParams.get("url")).toBe("https://example.com/a");
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
});
test("an unreachable page says so, but can still be posted", async () => {
previewRoutes({ reached: false });
const { user } = setup();
await user.type(linkBox(), "https://down.test/");
expect(await screen.findByText(/Couldn't load a preview/)).toBeInTheDocument();
expect(nextBtn()).toBeEnabled();
});
test("warns about an earlier dump of the same link", async () => {
previewRoutes({
dupes: [
{ id: "old", slug: "old-one", title: "Old one", username: "ann", createdAt: new Date(Date.now() - 3600_000).toISOString(), voteCount: 1, commentCount: 0 },
{ id: "older", title: "Older", username: "bob", createdAt: new Date(0).toISOString(), voteCount: 0, commentCount: 0 },
],
});
const { user, onClose } = setup();
await user.type(linkBox(), "https://x.test/");
const notice = await screen.findByText(/Already dumped by ann/);
expect(notice.closest("[role=status]")).toHaveTextContent("and 1 earlier dump");
await user.click(screen.getByRole("link", { name: "Old one" }));
expect(currentPath()).toBe("/dumps/old-one");
expect(onClose).toHaveBeenCalled();
});
test("the whole way through: post, then add to a playlist", async () => {
previewRoutes();
const created = makeDump({ id: "new", slug: "page-title", title: "Page title", userId: session.user.id });
route("POST /api/dumps", () => wire(created));
const mix = makePlaylist({ id: "p1", title: "Mix" });
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(mix), hasDump: false }]);
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user, injectDump, onClose } = setup();
await user.type(linkBox(), "example.com/a");
await screen.findByLabelText("Title");
await user.click(nextBtn());
expect(screen.getByText(/example\.com\/a|Page title/)).toBeInTheDocument();
const why = screen.getByLabelText("Why?");
await waitFor(() => expect(why).toHaveFocus());
await user.type(why, " Because ");
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Dump it" }));
await screen.findByRole("dialog", { name: "Add to playlist" });
expect(calls("POST /api/dumps")[0].body).toEqual({
url: "https://example.com/a",
comment: "Because",
isPrivate: true,
categoryIds: ["c1"],
});
expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" }));
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
const row = await screen.findByRole("checkbox", { name: /Mix/ });
expect(row).toHaveAttribute("aria-checked", "false");
row.focus();
await user.keyboard(" ");
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true"));
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/p1/dumps/new");
await user.click(screen.getByRole("button", { name: "Done" }));
expect(onClose).toHaveBeenCalled();
});
test("a refused playlist toggle leaves the box as it was", async () => {
previewRoutes();
route("POST /api/dumps", () => wire(makeDump({ id: "new" })));
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(makePlaylist({ title: "Mix" })), hasDump: false }]);
route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
await user.click(await screen.findByRole("button", { name: "Dump it" }));
const row = await screen.findByRole("checkbox", { name: /Mix/ });
await user.click(row);
await act(async () => {});
expect(row).toHaveAttribute("aria-checked", "false");
});
test("a title typed by the poster is sent; a suggested one isn't", async () => {
previewRoutes();
route("POST /api/dumps", () => wire(makeDump()));
route("GET /api/playlists/by-dump/:id/memberships", () => []);
const { user } = setup();
await user.type(linkBox(), "https://a.test/");
await waitFor(() => expect(screen.getByLabelText("Title")).toHaveValue("Page title"));
await user.click(screen.getByRole("button", { name: "Edit title" }));
const title = screen.getByLabelText("Title");
await waitFor(() => expect(title).toHaveFocus());
await user.clear(title);
await user.type(title, "My title");
// The suggestion can be had back.
await user.click(screen.getByRole("button", { name: "Reset to the suggested title" }));
expect(title).toHaveValue("Page title");
await user.click(screen.getByRole("button", { name: "Edit title" }));
await user.clear(title);
await user.type(title, "Mine after all");
await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" }));
await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1));
expect(calls("POST /api/dumps")[0].body).toMatchObject({ title: "Mine after all" });
});
test("Enter in the link field goes to the next step rather than posting", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
expect(screen.getByLabelText("Why?")).toBeInTheDocument();
expect(calls("POST /api/dumps")).toHaveLength(0);
await user.click(screen.getByRole("button", { name: "← Back" }));
expect(linkBox()).toHaveValue("https://a.test/");
});
test("a failed post shows the server's message and stays put", async () => {
previewRoutes();
route("POST /api/dumps", () => fail("That URL isn't allowed", 400));
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
await user.click(screen.getByRole("button", { name: "Dump it" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to post");
expect(alert).toHaveTextContent("That URL isn't allowed");
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
});
test("starts from a shared URL", async () => {
previewRoutes();
setup({ initialUrl: "https://shared.test/x" });
expect(linkBox()).toHaveValue("https://shared.test/x");
await screen.findByLabelText("Title");
});
test("a link pasted anywhere in the modal lands in the link field", async () => {
previewRoutes();
setup();
act(() => {
const e = new Event("paste", { bubbles: true }) as ClipboardEvent;
Object.defineProperty(e, "clipboardData", {
value: { files: [], getData: () => "pasted.test/page" },
});
screen.getByRole("dialog").dispatchEvent(e);
});
expect(linkBox()).toHaveValue("https://pasted.test/page");
});
});
describe("DumpCreateModal: drafts", () => {
test("a dismissed draft comes back, and can be dropped", async () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({
url: "https://draft.test/",
title: "",
titleFromUser: false,
comment: "half-written",
isPublic: false,
categoryIds: ["c1"],
}));
previewRoutes();
const { user } = setup();
expect(screen.getByText("Picked up where you left off.")).toBeInTheDocument();
expect(linkBox()).toHaveValue("https://draft.test/");
await user.click(nextBtn());
expect(screen.getByLabelText("Why?")).toHaveValue("half-written");
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "← Back" }));
await user.click(screen.getByRole("button", { name: "Start fresh" }));
expect(linkBox()).toHaveValue("");
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
});
test("what's typed is kept as it's typed", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "https://keep.test/");
expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe("https://keep.test/");
});
test("a shared URL starts clean rather than merging an old draft", () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({ url: "https://old.test/", comment: "old why" }));
previewRoutes();
setup({ initialUrl: "https://new.test/" });
expect(screen.queryByText("Picked up where you left off.")).not.toBeInTheDocument();
expect(linkBox()).toHaveValue("https://new.test/");
});
});
describe("DumpCreateModal: a file", () => {
class FakeXHR {
static last: FakeXHR;
status = 0;
statusText = "";
responseText = "";
body: FormData | null = null;
private l: Record<string, Array<(e: unknown) => void>> = {};
private u: Record<string, Array<(e: unknown) => void>> = {};
upload = { addEventListener: (t: string, f: (e: unknown) => void) => (this.u[t] ??= []).push(f) };
constructor() {
FakeXHR.last = this;
}
open() {}
setRequestHeader() {}
addEventListener(t: string, f: (e: unknown) => void) {
(this.l[t] ??= []).push(f);
}
send(body: FormData) {
this.body = body;
}
progress(f: number) {
this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true }));
}
respond(status: number, data: unknown) {
this.status = status;
this.responseText = JSON.stringify(data);
this.l.load?.forEach((fn) => fn({}));
}
}
afterEach(() => vi.unstubAllGlobals());
const song = () => new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" });
test("a dropped file names the dump and uploads with progress", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
route("GET /api/playlists/by-dump/:id/memberships", () => []);
const { user } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
expect(screen.queryByPlaceholderText("Paste a link…")).not.toBeInTheDocument();
expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument();
expect(screen.getByLabelText("Title")).toHaveValue("my song-final");
await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" }));
act(() => FakeXHR.last.progress(0.42));
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
expect(screen.getByRole("button", { name: "Uploading 42%" })).toBeDisabled();
const fd = FakeXHR.last.body!;
expect(fd.get("title")).toBe("my song-final");
expect(fd.get("isPrivate")).toBe("false");
expect(fd.get("categoryIds")).toBe("[]");
await act(async () => FakeXHR.last.respond(201, { success: true, data: wire(makeDump({ id: "f" })) }));
await screen.findByRole("dialog", { name: "Add to playlist" });
});
test("closing with a file attached asks first", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
const { user, onClose } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
const confirm = screen.getByText(/The attached file won't be kept/).closest("[role=alert]")!;
await user.click(within(confirm as HTMLElement).getByRole("button", { name: "Keep editing" }));
expect(screen.queryByText(/The attached file won't be kept/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: "Close anyway" }));
expect(onClose).toHaveBeenCalled();
});
test("without a file, closing just closes", async () => {
const { user, onClose } = setup();
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
test("removing the file offers the link field again", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
const { user } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
await user.click(screen.getByRole("button", { name: "Remove file" }));
expect(linkBox()).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,105 @@
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { useState } from "react";
import { Modal } from "../../components/Modal.tsx";
import { ConfirmModal } from "../../components/ConfirmModal.tsx";
const wrap = (ui: React.ReactNode) =>
render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
describe("Modal", () => {
test("is a labelled dialog that locks the page's scroll", () => {
wrap(<Modal title="Settings" onClose={() => {}}>body</Modal>);
expect(screen.getByRole("dialog", { name: "Settings" })).toHaveTextContent(
"body",
);
expect(document.documentElement.style.overflowY).toBe("hidden");
});
test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose}><button type="button">inside</button></Modal>);
await user.click(screen.getByText("inside"));
expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(document.querySelector(".modal-backdrop")!);
expect(onClose).toHaveBeenCalledTimes(3);
});
test("onBeforeClose can refuse", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose} onBeforeClose={() => false}>x</Modal>);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).not.toHaveBeenCalled();
});
test("an Escape something inside already handled is left alone", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(
<Modal title="T" onClose={onClose}>
<input onKeyDown={(e) => e.key === "Escape" && e.preventDefault()} />
</Modal>,
);
await user.click(screen.getByRole("textbox"));
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
});
describe("ConfirmModal", () => {
test("asks, confirms and cancels", async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
const user = userEvent.setup();
wrap(
<ConfirmModal message="Really?" onConfirm={onConfirm} onCancel={onCancel} />,
);
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveAccessibleDescription("Really?");
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.keyboard("{Escape}");
await user.click(document.querySelector(".modal-backdrop")!);
expect(onCancel).toHaveBeenCalledTimes(3);
});
test("takes a custom label", () => {
wrap(
<ConfirmModal message="m" confirmLabel="Discard" onConfirm={() => {}} onCancel={() => {}} />,
);
expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument();
});
test("Escape on a confirm over a modal closes only the confirm", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
function Stacked() {
const [confirming, setConfirming] = useState(true);
return (
<Modal title="Chat" onClose={onClose}>
{confirming && (
<ConfirmModal
message="Delete this message?"
onConfirm={() => {}}
onCancel={() => setConfirming(false)}
/>
)}
</Modal>
);
}
wrap(<Stacked />);
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Chat" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,295 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, useState } from "react";
import { TextEditor, type TextEditorHandle } from "../../components/TextEditor.tsx";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
import { serveEmojibase } from "../emojibase.ts";
type Props = Partial<React.ComponentProps<typeof TextEditor>>;
function setup(props: Props = {}, initial = "") {
const onChange = vi.fn();
function Harness() {
const [value, setValue] = useState(initial);
return (
<TextEditor
value={value}
onChange={(v) => {
setValue(v);
onChange(v);
}}
{...props}
/>
);
}
const user = userEvent.setup();
renderWithContexts(<Harness />, { session: makeSession() });
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
return { user, box, onChange };
}
function imageFile(name = "a.png", type = "image/png") {
return new File([new Uint8Array([1, 2, 3])], name, { type });
}
describe("TextEditor basics", () => {
test("types through to onChange", async () => {
const { user, box, onChange } = setup({ placeholder: "Say something" });
expect(box).toHaveAttribute("placeholder", "Say something");
await user.type(box, "hello");
expect(onChange).toHaveBeenLastCalledWith("hello");
expect(box).toHaveValue("hello");
});
test("Ctrl/Cmd+Enter submits; plain Enter goes to onKeyDown", async () => {
const onSubmit = vi.fn();
const onKeyDown = vi.fn();
const { user, box } = setup({ onSubmit, onKeyDown });
await user.type(box, "x");
await user.keyboard("{Control>}{Enter}{/Control}");
expect(onSubmit).toHaveBeenCalledTimes(1);
await user.keyboard("{Meta>}{Enter}{/Meta}");
expect(onSubmit).toHaveBeenCalledTimes(2);
await user.keyboard("{Enter}");
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
});
test("an Enter confirming an IME candidate does nothing else", () => {
const onSubmit = vi.fn();
const onKeyDown = vi.fn();
const { box } = setup({ onSubmit, onKeyDown });
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true, isComposing: true });
fireEvent.keyDown(box, { key: "Enter", keyCode: 229 });
expect(onSubmit).not.toHaveBeenCalled();
expect(onKeyDown).not.toHaveBeenCalled();
});
test("Escape cancels when there's nothing of its own to close", async () => {
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.click(box);
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
test("the counter shows from 80% of the limit and flags going over", async () => {
const { user, box } = setup({ maxLength: 10 }, "1234567");
expect(screen.queryByText(/\/ 10/)).not.toBeInTheDocument();
await user.type(box, "8");
expect(screen.getByText("8 / 10")).toBeInTheDocument();
await user.type(box, "901");
expect(screen.getByText("11 / 10")).toHaveClass("text-editor-count--danger");
expect(box).toHaveAttribute("aria-invalid", "true");
});
test("countFrom overrides when the counter appears", () => {
setup({ maxLength: 100, countFrom: 0 });
expect(screen.getByText("0 / 100")).toBeInTheDocument();
});
test("the handle focuses at the end", () => {
const ref = createRef<TextEditorHandle>();
renderWithContexts(<TextEditor ref={ref} value="abc" onChange={() => {}} />);
act(() => ref.current!.focus({ atEnd: true }));
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(box).toHaveFocus();
expect(box.selectionStart).toBe(3);
});
test("disabled", () => {
setup({ disabled: true });
expect(screen.getByRole("textbox")).toBeDisabled();
expect(screen.getByRole("button", { name: "Insert emoji" })).toBeDisabled();
});
});
describe("mentions", () => {
const users = [
{ id: "1", username: "alice", avatarMime: null },
{ id: "2", username: "alex", avatarMime: null },
];
test("@ opens a listbox; Enter picks and adds a space", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "hi @al");
const list = await screen.findByRole("listbox");
expect(within(list).getAllByRole("option")).toHaveLength(2);
expect(box).toHaveAttribute("aria-activedescendant");
await user.keyboard("{ArrowDown}{Enter}");
expect(box).toHaveValue("hi @alex ");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
test("clicking a suggestion picks it", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "@ali");
await user.click(await screen.findByRole("option", { name: /alice/ }));
expect(box).toHaveValue("@alice ");
});
test("Escape closes the list, and only a second Escape cancels", async () => {
route("GET /api/users/search", () => users);
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.type(box, "@al");
await screen.findByRole("listbox");
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
test("Enter with the list open picks rather than submitting", async () => {
route("GET /api/users/search", () => users);
const onKeyDown = vi.fn();
const { user, box } = setup({ onKeyDown });
await user.type(box, "@al");
await screen.findByRole("listbox");
await user.keyboard("{Enter}");
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
expect(box).toHaveValue("@alice ");
});
test("leaving the field closes the list", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "@al");
await screen.findByRole("listbox");
fireEvent.blur(box);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
describe("image upload", () => {
test("a pasted image is uploaded and inserted as markdown", async () => {
route("POST /api/attachments", () => ({ id: "att1" }));
const { user, box } = setup({}, "look: ");
await user.click(box);
box.setSelectionRange(6, 6);
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
await waitFor(() =>
expect(box).toHaveValue("look: ![](http://localhost:8000/api/attachments/att1)")
);
const [req] = calls("POST /api/attachments");
expect(req.body).toBeInstanceOf(FormData);
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
});
test("a paste carrying text pastes the text", () => {
const { box } = setup();
const event = fireEvent.paste(box, {
clipboardData: { types: ["text/plain", "Files"], files: [imageFile()] },
});
expect(event).toBe(true); // not prevented
expect(calls("POST /api/attachments")).toHaveLength(0);
});
test("while uploading: read-only, says so, and holds Enter", async () => {
const pending = deferred();
route("POST /api/attachments", () => pending.promise);
const onKeyDown = vi.fn();
const { box } = setup({ onKeyDown });
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
expect(await screen.findByRole("status")).toHaveTextContent("Uploading image…");
expect(box).toHaveAttribute("readonly");
expect(box).toHaveAttribute("aria-busy", "true");
fireEvent.keyDown(box, { key: "Enter" });
expect(onKeyDown).not.toHaveBeenCalled();
await act(async () =>
pending.resolve(new Response(JSON.stringify({ success: true, data: { id: "z" } })))
);
await waitFor(() => expect(box).not.toHaveAttribute("readonly"));
});
test("a failed upload is reported", async () => {
route("POST /api/attachments", () => fail("Too big", 413));
const { box } = setup();
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
expect(await screen.findByRole("alert")).toHaveTextContent("The image couldn't be uploaded.");
expect(box).toHaveValue("");
expect(box).toHaveAttribute("aria-describedby");
});
test("dropping a non-image is caught and explained", () => {
const { box } = setup();
const dataTransfer = { types: ["Files"], files: [imageFile("a.pdf", "application/pdf")], dropEffect: "" };
const over = fireEvent.dragOver(box, { dataTransfer });
expect(over).toBe(false); // prevented: the browser mustn't open the file
fireEvent.drop(box, { dataTransfer });
expect(screen.getByRole("alert")).toHaveTextContent("Only images can be dropped here.");
expect(calls("POST /api/attachments")).toHaveLength(0);
});
test("dropped images upload; dragged text is left to the browser", async () => {
route("POST /api/attachments", (req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` }));
const { box } = setup();
const textDrag = fireEvent.dragOver(box, { dataTransfer: { types: ["text/plain"], files: [] } });
expect(textDrag).toBe(true);
fireEvent.drop(box, {
dataTransfer: { types: ["Files"], files: [imageFile("a.png"), imageFile("b.png")] },
});
await waitFor(() => expect(box.value).toContain("attachments/n1"));
expect(box.value.split("\n")).toHaveLength(2);
});
});
describe("emoji", () => {
test("':' and two letters opens the picker with that query; Escape closes only it", async () => {
serveEmojibase();
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.type(box, "hi :astro");
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
expect(search).toHaveValue("astro");
expect(box).toHaveFocus();
await user.keyboard("{Escape}");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
expect(box).toHaveValue("hi :astro");
});
test("Enter takes the best match and replaces the :query", async () => {
serveEmojibase();
const { user, box } = setup();
await user.type(box, "go :astronaut");
await screen.findByRole("gridcell", { name: "Astronaut" });
await user.keyboard("{Enter}");
expect(box).toHaveValue("go 🧑‍🚀 ");
});
test("emoticons and times don't open it", async () => {
serveEmojibase();
const { user, box } = setup();
await user.type(box, "ok :D at 12:30");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
});
test("the button browses and inserts at the caret", async () => {
serveEmojibase();
const { user, box } = setup({}, "ab");
await user.click(box);
box.setSelectionRange(1, 1);
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
expect(screen.getByRole("button", { name: "Insert emoji" })).toHaveAttribute("aria-expanded", "true");
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
await waitFor(() => expect(search).toHaveFocus());
await user.type(search, "astronaut");
await user.click(await screen.findByRole("gridcell", { name: "Astronaut" }));
expect(box).toHaveValue("a🧑‍🚀b");
});
test("a press outside puts the picker away", async () => {
serveEmojibase();
const { user } = setup();
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
await screen.findByRole("searchbox", { name: "Search emoji" });
await user.click(document.body);
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,292 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef } from "react";
import { Avatar } from "../../components/Avatar.tsx";
import { VoteButton } from "../../components/VoteButton.tsx";
import { LikeButton } from "../../components/LikeButton.tsx";
import { FollowPlaylistButton, FollowUserButton } from "../../components/FollowButton.tsx";
import { UserListPopover } from "../../components/UserListPopover.tsx";
import { NotificationBell } from "../../components/NotificationBell.tsx";
import { ChatButton } from "../../components/ChatButton.tsx";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
import { WSContext } from "../../contexts/WSContext.ts";
describe("Avatar", () => {
test("an image when the user has one, versioned", () => {
renderWithContexts(<Avatar userId="u1" username="ann" hasAvatar version={7} />);
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
"src",
"http://localhost:8000/api/avatars/u1?v=7",
);
});
test("initials otherwise, and after the image fails", () => {
renderWithContexts(<Avatar userId="u1" username="bob" hasAvatar />);
fireEvent.error(screen.getByRole("img"));
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByTitle("bob")).toHaveTextContent("B");
});
test("a live override wins over stale props, and retries a failed image", () => {
const { rerender } = renderWithContexts(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: false, version: 1 } }}>
<Avatar userId="u1" username="cy" hasAvatar />
</AvatarOverrideContext.Provider>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
rerender(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: true, version: 2 } }}>
<Avatar userId="u1" username="cy" hasAvatar={false} />
</AvatarOverrideContext.Provider>,
);
// rerender replaces the tree under the same router wrapper
expect(screen.getByRole("img")).toHaveAttribute("src", expect.stringContaining("?v=2"));
});
});
describe("VoteButton / LikeButton", () => {
test("upvote and remove, passing the count on screen", async () => {
const onCast = vi.fn();
const onRemove = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithContexts(
<VoteButton dumpId="d" count={0} voted={false} onCast={onCast} onRemove={onRemove} />,
);
await user.click(screen.getByRole("button", { name: "Upvote" }));
expect(onCast).toHaveBeenCalledWith("d", 0);
rerender(<VoteButton dumpId="d" count={3} voted onCast={onCast} onRemove={onRemove} />);
await user.click(screen.getByRole("button", { name: "Remove vote" }));
expect(onRemove).toHaveBeenCalledWith("d", 3);
});
test("disabled for guests, with a hint", () => {
renderWithContexts(
<VoteButton dumpId="d" count={1} voted={false} disabled onCast={() => {}} onRemove={() => {}} />,
);
const btn = screen.getByRole("button", { name: "Upvote" });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute("title", "Log in to vote");
});
test("the count opens who voted, without voting", async () => {
const onCast = vi.fn();
route("GET /api/dumps/:id/voters", () => page([makeUser({ username: "fan" })]));
const user = userEvent.setup();
renderWithContexts(
<VoteButton dumpId="d1" count={1} voted={false} onCast={onCast} onRemove={() => {}} />,
);
await user.click(screen.getByText("1"));
expect(onCast).not.toHaveBeenCalled();
expect(await screen.findByRole("menuitem", { name: /fan/ })).toHaveAttribute(
"href",
"/users/fan",
);
});
test("likes work the same way", async () => {
const onLike = vi.fn();
const onUnlike = vi.fn();
route("GET /api/comments/:id/likers", () => page([]));
const user = userEvent.setup();
const { rerender } = renderWithContexts(
<LikeButton commentId="c" count={2} liked={false} onLike={onLike} onUnlike={onUnlike} />,
);
await user.click(screen.getByRole("button", { name: "Like" }));
expect(onLike).toHaveBeenCalledWith("c", 2);
await user.click(screen.getByText("2"));
expect(await screen.findByText("No one yet.")).toBeInTheDocument();
rerender(<LikeButton commentId="c" count={2} liked onLike={onLike} onUnlike={onUnlike} />);
await user.click(screen.getByRole("button", { name: "Remove like" }));
expect(onUnlike).toHaveBeenCalledWith("c", 2);
});
});
describe("UserListPopover", () => {
function Harness({ onClose = () => {} }: { onClose?: () => void }) {
const ref = useRef<HTMLSpanElement>(null);
return (
<>
<span ref={ref}>anchor</span>
<button type="button">outside</button>
<UserListPopover anchorRef={ref} onClose={onClose} fetchUrl="http://localhost:8000/api/x/voters" />
</>
);
}
test("pages through the list", async () => {
route("GET /api/x/voters", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "a" })], true)
: page([makeUser({ username: "b" })], false));
const user = userEvent.setup();
renderWithContexts(<Harness />, { session: makeSession() });
expect(screen.getByText("Loading…")).toBeInTheDocument();
await screen.findByText("a");
await user.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("b");
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(/^Bearer /);
});
test("shows the server's error, and a network one", async () => {
route("GET /api/x/voters", () => fail("Dump not found", 404), { once: true });
const first = renderWithContexts(<Harness />);
await screen.findByText("Dump not found");
first.unmount();
route("GET /api/x/voters", () => new TypeError("offline"));
renderWithContexts(<Harness />);
await screen.findByText("Could not load.");
});
test("a failed next page lets you try again", async () => {
route("GET /api/x/voters", () => page([makeUser({ username: "a" })], true), { once: true });
route("GET /api/x/voters", () => new TypeError("offline"), { once: true });
route("GET /api/x/voters", () => page([makeUser({ username: "b" })], false));
const user = userEvent.setup();
renderWithContexts(<Harness />);
await screen.findByText("a");
await user.click(screen.getByRole("button", { name: "Load more" }));
const retry = await screen.findByRole("button", { name: "Load more" });
expect(retry).toBeEnabled();
await user.click(retry);
await screen.findByText("b");
});
test("closes on Escape, an outside press and a scroll — not on the anchor", async () => {
route("GET /api/x/voters", () => page([]));
const onClose = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onClose={onClose} />);
await screen.findByText("No one yet.");
await user.click(screen.getByText("anchor"));
await user.click(screen.getByText("No one yet."));
expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
await user.click(screen.getByText("outside"));
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
expect(onClose).toHaveBeenCalledTimes(3);
});
});
describe("FollowButton", () => {
const me = makeSession({ id: "me" });
test("hidden for guests and on yourself", () => {
const { unmount } = renderWithContexts(
<FollowUserButton targetUserId="x" targetUsername="x" />,
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
unmount();
renderWithContexts(<FollowUserButton targetUserId="me" targetUsername="me" />, { session: me });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
test("follow and unfollow a user", async () => {
const followUser = vi.fn();
const unfollowUser = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<FollowUserButton targetUserId="u" targetUsername="ann" />,
{ session: me, follows: { followUser, unfollowUser } },
);
const btn = screen.getByRole("button", { name: "Follow ann" });
expect(btn).toHaveTextContent("Follow");
await user.click(btn);
expect(followUser).toHaveBeenCalledWith("u");
expect(unfollowUser).not.toHaveBeenCalled();
});
test("shows Following, and unfollows", async () => {
const unfollowUser = vi.fn();
const user = userEvent.setup();
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
session: me,
follows: { followedUserIds: new Set(["u"]), unfollowUser },
});
const btn = screen.getByRole("button", { name: "Unfollow ann" });
expect(btn).toHaveTextContent("Following");
await user.click(btn);
expect(unfollowUser).toHaveBeenCalledWith("u");
});
test("disabled until follows have loaded", () => {
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
session: me,
follows: { isLoaded: false },
});
expect(screen.getByRole("button")).toBeDisabled();
});
test("playlists: only public ones can be followed", async () => {
const followPlaylist = vi.fn();
const unfollowPlaylist = vi.fn();
const user = userEvent.setup();
const priv = renderWithContexts(
<FollowPlaylistButton targetPlaylistId="p" isPublic={false} />,
{ session: me },
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
priv.unmount();
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
session: me,
follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) },
});
await user.click(screen.getByRole("button", { name: "Unfollow playlist" }));
expect(unfollowPlaylist).toHaveBeenCalledWith("p");
});
});
describe("NotificationBell / ChatButton", () => {
test("the bell shows the unread count and goes to notifications", async () => {
const user = userEvent.setup();
renderWithContexts(<NotificationBell />, { ws: { unreadNotificationCount: 120 } });
const bell = screen.getByRole("button", { name: "Notifications (120 unread)" });
expect(bell).toHaveTextContent("99+");
await user.click(bell);
expect(currentPath()).toBe("/notifications");
});
test("the bell rings for a new notification, not for one already there", () => {
vi.useFakeTimers();
try {
const n1 = makeNotification({ id: "n1" });
const withLast = (n: ReturnType<typeof makeNotification>) => (
<WSContext.Provider value={{ ...defaultOf(WSContext), lastNotification: n }}>
<NotificationBell />
</WSContext.Provider>
);
const { rerender } = renderWithContexts(withLast(n1));
const bell = () => screen.getByRole("button", { name: "Notifications" });
act(() => vi.advanceTimersByTime(10));
expect(bell()).not.toHaveClass("notification-bell--ringing");
rerender(withLast(makeNotification({ id: "n2" })));
act(() => vi.advanceTimersByTime(10));
expect(bell()).toHaveClass("notification-bell--ringing");
act(() => vi.advanceTimersByTime(700));
expect(bell()).not.toHaveClass("notification-bell--ringing");
} finally {
vi.useRealTimers();
}
});
test("the chat button opens the chat and shows unread", async () => {
const openChat = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
<ChatButton />
</ChatContext.Provider>,
{ ws: { unreadChatCount: 4 } },
);
await user.click(screen.getByRole("button", { name: "Chat (4 unread)" }));
expect(openChat).toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument());
});
});

View File

@@ -0,0 +1,307 @@
import { describe, expect, test, vi } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Markdown } from "../../components/Markdown.tsx";
import Thumbnail from "../../components/Thumbnail.tsx";
import ThumbnailPlaceholder from "../../components/ThumbnailPlaceholder.tsx";
import RichContentCard from "../../components/RichContentCard.tsx";
import { DumpCard } from "../../components/DumpCard.tsx";
import { hueFromString } from "../../utils/thumbnailTint.ts";
import { markDumpVisited } from "../../utils/visited.ts";
import { makeCategory, makeDump, makeSession } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
describe("Markdown", () => {
const md = (text: string, inline = false) =>
renderWithContexts(<Markdown inline={inline}>{text}</Markdown>);
test("renders GFM", () => {
md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done");
expect(screen.getByText("bold").tagName).toBe("STRONG");
expect(screen.getByText("gone").tagName).toBe("DEL");
expect(screen.getByRole("table").parentElement).toHaveClass("md-table-wrap");
expect(screen.getByRole("checkbox")).toBeChecked();
});
test("@mentions link to the profile, in-app", async () => {
md("hi @ann and bob@mail.com");
const link = screen.getByRole("link", { name: "@ann" });
expect(link).toHaveAttribute("href", "/users/ann");
expect(link).not.toHaveAttribute("target");
// GFM autolinks the address itself; it isn't a mention of "mail".
expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute(
"href",
"mailto:bob@mail.com",
);
expect(document.querySelector('a[href="/users/mail"]')).toBeNull();
await userEvent.click(link);
expect(currentPath()).toBe("/users/ann");
});
test("mentions in code and inside URLs are left alone", () => {
md("`@ann` and https://mastodon.social/@bob");
expect(screen.getByText("@ann").tagName).toBe("CODE");
expect(screen.getByRole("link")).toHaveAttribute(
"href",
"https://mastodon.social/@bob",
);
});
test("external links open in a new tab; internal and same-origin ones don't", () => {
md(
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`,
);
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("target", "_blank");
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute("target");
expect(screen.getByRole("link", { name: "same" })).toHaveAttribute("href", "/playlists/p?x=1#y");
expect(screen.getByRole("link", { name: "file" })).toHaveAttribute("target", "_blank");
});
test("a single newline is a line break", () => {
const { container } = md("one\ntwo\nthree");
expect(container.querySelectorAll("br")).toHaveLength(2);
});
test("code blocks keep their newlines", () => {
const { container } = md("```\na\nb\n```");
expect(container.querySelector("pre")!.textContent).toBe("a\nb\n");
expect(container.querySelectorAll("br")).toHaveLength(0);
});
test("images load lazily; inline teasers drop them", () => {
const full = md("![cat](https://x.test/cat.png)");
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy");
full.unmount();
md("![cat](https://x.test/cat.png) text", true);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByText("text")).toBeInTheDocument();
});
test("raw HTML is not rendered", () => {
const { container } = md("<script>alert(1)</script><b>x</b>");
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("b")).toBeNull();
});
test("javascript: links are neutralised", () => {
md("[click](javascript:alert(1))");
const link = screen.getByText("click").closest("a");
expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
});
});
describe("Thumbnail", () => {
const placeholder = { url: "https://site.test/page", siteName: "Site" };
test("direct, then proxied, then the placeholder", () => {
renderWithContexts(
<Thumbnail src="https://img.test/a.png" alt="art" placeholder={placeholder} />,
);
const img = screen.getByRole("img", { name: "art" });
expect(img).toHaveAttribute("src", "https://img.test/a.png");
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
fireEvent.error(img);
expect(screen.getByRole("img", { name: "art" }).getAttribute("src")).toContain(
"/api/proxy-image?url=",
);
fireEvent.error(screen.getByRole("img", { name: "art" }));
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
expect(screen.getByText("S")).toBeInTheDocument();
});
test("our own images skip the proxy retry", () => {
renderWithContexts(
<Thumbnail src="http://localhost:8000/api/thumbnails/d" alt="t" placeholder={placeholder} />,
);
fireEvent.error(screen.getByRole("img", { name: "t" }));
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
});
test("no src: straight to the placeholder; a new src tries again", () => {
const { rerender } = renderWithContexts(<Thumbnail placeholder={placeholder} alt="t" />);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
rerender(<Thumbnail src="https://img.test/b.png" placeholder={placeholder} alt="t" />);
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();
});
});
describe("ThumbnailPlaceholder", () => {
test("tints by the page colour, or by hostname", () => {
const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="#123456" />,
);
const box = () => container.querySelector<HTMLElement>(".thumb-placeholder")!;
expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456");
rerender(<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="url(evil)" />);
expect(box().style.getPropertyValue("--thumb-tint")).toBe(hueFromString("site.test"));
});
test("favicon, then the initial when it fails; a glyph wins over the initial", () => {
const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://site.test" faviconUrl="https://site.test/f.ico" />,
);
const icon = container.querySelector("img")!;
fireEvent.error(icon);
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("S")).toBeInTheDocument();
rerender(<ThumbnailPlaceholder seed="clip.mp4" glyph="🎬" />);
expect(screen.getByText("🎬")).toBeInTheDocument();
});
});
describe("RichContentCard", () => {
const yt = {
type: "youtube",
url: "https://youtube.com/watch?v=x",
title: "A video",
siteName: "YouTube",
description: "About it",
thumbnailUrl: "https://i.ytimg.com/x.jpg",
embedUrl: "https://www.youtube.com/embed/x",
};
test("full card: play button, source link and details", async () => {
const play = vi.fn();
renderWithContexts(<RichContentCard richContent={yt} dumpHref="/dumps/d" />, {
player: { play },
});
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute("href", yt.url);
expect(screen.getByText("About it")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "embed",
embedUrl: yt.embedUrl,
dumpHref: "/dumps/d",
}));
});
test("compact: shows Pause while this card plays", () => {
renderWithContexts(<RichContentCard richContent={yt} compact />, {
player: {
playing: true,
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
},
});
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass("is-playing");
});
test("compact and unplayable: a link to the source", () => {
renderWithContexts(
<RichContentCard richContent={{ type: "link", url: "https://blog.test/p", siteName: "Blog" }} compact />,
);
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
"href",
"https://blog.test/p",
);
});
test("a dump thumbnail overrides the provider's", () => {
renderWithContexts(
<RichContentCard richContent={yt} thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d" />,
);
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
"src",
"http://localhost:8000/api/thumbnails/d",
);
});
test("a native stream that is the active item toggles instead of restarting", async () => {
const togglePlay = vi.fn();
const play = vi.fn();
const bc = { type: "bandcamp", url: "https://a.bandcamp.com/album/x", embedUrl: "https://bandcamp.com/EmbeddedPlayer/1" };
renderWithContexts(<RichContentCard richContent={bc} compact />, {
player: {
togglePlay,
play,
playing: true,
current: {
kind: "stream",
type: "bandcamp",
streamUrl: "s",
resolveUrl: bc.url,
resolveIndex: 0,
resolvedAt: 0,
},
},
});
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(togglePlay).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
});
describe("DumpCard", () => {
const props = {
voteCount: 3,
voted: false,
canVote: true,
castVote: vi.fn(),
removeVote: vi.fn(),
};
test("title, teaser, date, comment count and categories", () => {
const dump = makeDump({
id: "d1",
slug: "nice",
title: "Nice find",
comment: "Look **here**",
commentCount: 2,
categoryIds: ["c1", "missing"],
});
renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>, {
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
});
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute("href", "/dumps/nice");
expect(screen.getByText("here").tagName).toBe("STRONG");
expect(screen.getByText("2 comments")).toBeInTheDocument();
expect(screen.getByText("1 minute ago")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
});
test("an unread dot until visited, never on your own", () => {
const dump = makeDump({ id: "d2" });
const first = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>);
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount();
const own = renderWithContexts(<ul><DumpCard dump={dump} {...props} isOwner /></ul>);
expect(own.container.querySelector(".unread-dot")).toBeNull();
own.unmount();
markDumpVisited("d2");
const seen = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>);
expect(seen.container.querySelector(".unread-dot")).toBeNull();
});
test("the private badge shows to the owner", () => {
renderWithContexts(
<ul><DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner /></ul>,
);
expect(screen.getByText("private")).toBeInTheDocument();
});
test("clicking the card opens the dump; voting doesn't", async () => {
const castVote = vi.fn();
const dump = makeDump({ id: "d3" });
renderWithContexts(
<ul><DumpCard dump={dump} {...props} castVote={castVote} /></ul>,
{ session: makeSession() },
);
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
expect(castVote).toHaveBeenCalledWith("d3", 3);
expect(currentPath()).toBe("/");
await userEvent.click(screen.getByText("1 minute ago"));
expect(currentPath()).toBe("/dumps/d3");
});
test("file dumps get a file preview; bare URLs a placeholder", () => {
const { container } = renderWithContexts(
<ul>
<DumpCard dump={makeDump({ kind: "file", fileName: "a.pdf", fileMime: "application/pdf", fileSize: 10, url: undefined })} {...props} />
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
</ul>,
);
expect(container.querySelectorAll(".thumb-placeholder").length).toBeGreaterThan(0);
const cards = container.querySelectorAll(".dump-card");
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,430 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import { useState } from "react";
import { SearchBar } from "../../components/SearchBar.tsx";
import { UserMenu } from "../../components/UserMenu.tsx";
import { PresenceRow } from "../../components/PresenceRow.tsx";
import { CategorySwitcher } from "../../components/CategorySwitcher.tsx";
import { CategorySelect } from "../../components/CategorySelect.tsx";
import { TabBar } from "../../components/TabBar.tsx";
import { FeedTabBar } from "../../components/FeedTabBar.tsx";
import { AppHeader } from "../../components/AppHeader.tsx";
import { ChatFab } from "../../components/ChatFab.tsx";
import { DumpFab } from "../../components/DumpFab.tsx";
import { Tooltip } from "../../components/Tooltip.tsx";
import { CountedInput } from "../../components/CountedInput.tsx";
import { PageError } from "../../components/PageError.tsx";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts";
import { WSContext } from "../../contexts/WSContext.ts";
import type { AuthResponse, Category } from "../../model.ts";
import { makeCategory, makeSession, makeUser } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
const categories: Category[] = [
makeCategory({ id: "c1", slug: "music", name: "Music" }),
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
];
describe("SearchBar", () => {
function Harness(props: Partial<Parameters<typeof SearchBar>[0]>) {
const [value, setValue] = useState("");
return (
<SearchBar
expanded
value={value}
onValueChange={setValue}
onSubmit={() => {}}
onCancel={() => {}}
{...props}
/>
);
}
test("submits the trimmed query; ignores a blank one", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onSubmit={onSubmit} />);
const input = screen.getByRole("searchbox", { name: "Search" });
await user.type(input, " {Enter}");
expect(onSubmit).not.toHaveBeenCalled();
await user.type(input, " cats {Enter}");
expect(onSubmit).toHaveBeenCalledWith("cats");
expect(input).not.toHaveFocus();
});
test("Escape cancels instead of clearing", async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onCancel={onCancel} />);
await user.type(screen.getByRole("searchbox"), "dogs{Escape}");
expect(onCancel).toHaveBeenCalled();
expect(screen.getByRole("searchbox")).toHaveValue("dogs");
});
test("takes focus when it opens, not when it mounts open", () => {
const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} };
const { rerender } = renderWithContexts(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />);
expect(screen.getByRole("searchbox")).toHaveAttribute("tabindex", "-1");
rerender(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus();
});
});
describe("UserMenu", () => {
test("opens, links to profile and playlists, closes on Escape and outside", async () => {
const user = userEvent.setup();
renderWithContexts(
<>
<UserMenu user={makeUser({ username: "ann" })} />
<p>outside</p>
</>,
);
const trigger = screen.getByRole("button", { name: "User menu" });
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
"href",
"/users/ann/playlists",
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await user.click(trigger);
await user.click(screen.getByText("outside"));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await user.click(trigger);
await user.click(screen.getByRole("menuitem", { name: "@ann" }));
expect(currentPath()).toBe("/users/ann");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});
describe("PresenceRow", () => {
const online = (n: number) =>
Array.from({ length: n }, (_, i) => ({
userId: `u${i}`,
username: `user${i}`,
hasAvatar: false,
}));
test("nothing when nobody's online", () => {
renderWithContexts(<PresenceRow />);
expect(document.querySelector(".index-presence")).toBeNull();
});
test("up to six seats, the rest folded into a counter", () => {
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(9) } });
expect(screen.getAllByRole("link")).toHaveLength(5);
const more = screen.getByRole("img", { name: /4 more online/ });
expect(more).toHaveTextContent("+4");
expect(more).toHaveAttribute("title", "user5, user6, user7, user8");
});
test("six or fewer all show", () => {
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(6) } });
expect(screen.getAllByRole("link")).toHaveLength(6);
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
});
test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => {
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100);
renderWithContexts(
<div className="bar">
<div className="tabs"><span>a</span></div>
<PresenceRow fitWithin=".bar" fitBeside=".tabs" />
</div>,
{ ws: { onlineUsers: online(9) } },
);
expect(screen.getAllByRole("link")).toHaveLength(3);
width.mockReturnValue(0);
act(() => {
globalThis.dispatchEvent(new Event("resize"));
});
width.mockRestore();
});
});
describe("CategorySwitcher", () => {
test("hidden with no categories", () => {
renderWithContexts(<CategorySwitcher />, { route: "/~/hot", path: "/:x/:feedTab" });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
test("switches scope, keeping the tab", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, {
categories,
route: "/music/new",
path: "/:categorySlug/:feedTab",
});
const trigger = screen.getByRole("button", { name: "Music" });
await user.click(trigger);
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("option", { name: "Cinema" }));
expect(currentPath()).toBe("/cinema/new");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
test("All is the ~ scope; an unknown slug shows as itself", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, {
categories,
route: "/gone/hot",
path: "/:categorySlug/:feedTab",
});
await user.click(screen.getByRole("button", { name: "gone" }));
await user.click(screen.getByRole("option", { name: "All" }));
expect(currentPath()).toBe("/~/hot");
});
test("Escape closes the menu", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, { categories, route: "/~/hot", path: "/~/:feedTab" });
await user.click(screen.getByRole("button", { name: "All" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
describe("CategorySelect", () => {
test("toggle chips reflect the selection", async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
renderWithContexts(<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />, {
categories,
});
expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "false");
await user.click(screen.getByRole("button", { name: "Music" }));
expect(onToggle).toHaveBeenCalledWith("c1");
});
test("nothing without categories", () => {
renderWithContexts(<CategorySelect selected={new Set()} onToggle={() => {}} />);
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
});
});
describe("TabBar / FeedTabBar", () => {
test("TabBar marks the active tab and reports clicks", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<TabBar
tabs={[{ key: "a", label: "A" }, { key: "b", label: "B" }]}
activeTab="b"
onChange={onChange}
/>,
);
expect(screen.getByRole("button", { name: "B" })).toHaveClass("active");
await user.click(screen.getByRole("button", { name: "A" }));
expect(onChange).toHaveBeenCalledWith("a");
});
test("guests don't get Followed; tabs navigate within the scope", async () => {
const user = userEvent.setup();
renderWithContexts(<FeedTabBar />, { route: "/music/hot", path: "/:categorySlug/:feedTab", categories });
expect(screen.queryByRole("button", { name: "Followed" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active");
await user.click(screen.getByRole("button", { name: "Journal" }));
expect(currentPath()).toBe("/music/journal");
});
test("signed in, Followed is there", () => {
renderWithContexts(<FeedTabBar />, {
session: makeSession(),
route: "/~/followed",
path: "/~/:feedTab",
});
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active");
});
});
describe("AppHeader", () => {
test("guests get a login button", async () => {
const user = userEvent.setup();
renderWithContexts(<AppHeader />);
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(currentPath()).toBe("/login");
});
test("members get their links, chat, bell and the new-dump button", () => {
renderWithContexts(<AppHeader disableNew />, { session: makeSession({ username: "ann" }) });
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument();
const dump = screen.getByRole("button", { name: /Dump/ });
expect(dump).toBeDisabled();
expect(dump).toHaveAttribute("title", "Server unreachable");
});
test("shows the live-updates banner when disconnected", () => {
renderWithContexts(<AppHeader />, {
ws: { wsStatus: "disconnected", wsErrorMessage: "Trying to reconnect…" },
});
expect(screen.getByRole("alert")).toHaveTextContent(
"Live updates unavailable. Trying to reconnect…",
);
});
test("the search toggle opens and closes, giving focus back", async () => {
const user = userEvent.setup();
renderWithContexts(<AppHeader />);
const toggle = screen.getByRole("button", { name: "Search" });
await user.click(toggle);
expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute(
"aria-expanded",
"true",
);
const [input] = screen.getAllByRole("searchbox");
await user.type(input, "x");
await user.keyboard("{Escape}");
expect(screen.getByRole("button", { name: "Search" })).toHaveFocus();
expect(screen.getAllByRole("searchbox")[0]).toHaveValue("");
});
// Leaving search needs the real history stack (React Router keeps its
// index in history.state), so these use a BrowserRouter.
function Browser({ session }: { session?: AuthResponse }) {
const [authResponse, setAuthResponse] = useState(session ?? null);
function Loc() {
const l = useLocation();
return <output data-testid="loc">{l.pathname + l.search}</output>;
}
return (
<I18nProvider i18n={i18n}>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
<WSContext.Provider value={defaultOf(WSContext)}>
<BrowserRouter>
<Routes>
<Route path="*" element={<AppHeader />} />
</Routes>
<Loc />
</BrowserRouter>
</WSContext.Provider>
</AuthContext.Provider>
</I18nProvider>
);
}
const loc = () => screen.getByTestId("loc").textContent;
afterEach(() => window.history.replaceState(null, "", "/"));
test("several searches, then ✕, return to where search began", async () => {
window.history.replaceState(null, "", "/start");
const user = userEvent.setup();
render(<Browser />);
await user.click(screen.getByRole("button", { name: "Search" }));
await user.type(screen.getAllByRole("searchbox")[0], "cats{Enter}");
expect(loc()).toBe("/search?q=cats");
const bar = () => screen.getAllByRole("searchbox")[0];
expect(bar()).toHaveValue("cats");
await user.clear(bar());
await user.type(bar(), "dogs{Enter}");
expect(loc()).toBe("/search?q=dogs");
await user.click(screen.getByRole("button", { name: "Cancel search" }));
await waitFor(() => expect(loc()).toBe("/start"));
});
test("Escape on the results page reverts the query instead of leaving", async () => {
window.history.replaceState(null, "", "/search?q=cats");
const user = userEvent.setup();
render(<Browser />);
const bar = screen.getAllByRole("searchbox")[0];
expect(bar).toHaveValue("cats");
await user.type(bar, "zzz{Escape}");
expect(bar).toHaveValue("cats");
expect(loc()).toBe("/search?q=cats");
});
test("✕ on results that opened the session goes home", async () => {
window.history.replaceState(null, "", "/search?q=cats");
const user = userEvent.setup();
render(<Browser />);
await user.click(screen.getByRole("button", { name: "Cancel search" }));
expect(loc()).toBe("/");
});
});
describe("floating buttons", () => {
function withChat(openChat = vi.fn()) {
return (ui: React.ReactNode) => (
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
{ui}
</ChatContext.Provider>
);
}
test("guests get neither", () => {
renderWithContexts(withChat()(<><ChatFab /><DumpFab /></>));
expect(screen.queryByRole("button", { hidden: true })).not.toBeInTheDocument();
});
test("they show once the header scrolls away", () => {
const openChat = vi.fn();
renderWithContexts(
withChat(openChat)(
<>
<header className="app-header" />
<ChatFab />
<DumpFab />
</>,
),
{ session: makeSession(), ws: { unreadChatCount: 2 } },
);
const header = document.querySelector(".app-header")!;
// jsdom lays nothing out: put the header on screen first.
header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
const chat = document.querySelector<HTMLButtonElement>(".chat-fab")!;
expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)");
expect(chat).toHaveAttribute("aria-hidden", "true");
expect(chat).toHaveAttribute("tabindex", "-1");
expect(document.body).toHaveClass("has-fab");
header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
expect(chat).toHaveAttribute("aria-hidden", "false");
expect(chat).toHaveClass("chat-fab--visible");
act(() => chat.click());
expect(openChat).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "New dump" })).toHaveClass("dump-fab--visible");
});
});
describe("small pieces", () => {
test("Tooltip shows on hover", async () => {
const user = userEvent.setup();
renderWithContexts(<Tooltip text="Explained"><span>hover me</span></Tooltip>);
await user.hover(screen.getByText("hover me"));
expect(screen.getByText("Explained")).toBeInTheDocument();
await user.unhover(screen.getByText("hover me"));
expect(screen.queryByText("Explained")).not.toBeInTheDocument();
});
test("CountedInput counts toward its limit", () => {
renderWithContexts(<CountedInput value={"x".repeat(90)} maxLength={100} onChange={() => {}} />);
const count = screen.getByText("90 / 100");
expect(count).toHaveClass("text-editor-count--warn");
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100");
});
test("PageError wraps an alert in the page shell", () => {
renderWithContexts(<PageError message="Gone" />);
expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong");
expect(screen.getByRole("alert")).toHaveTextContent("Gone");
expect(document.querySelector(".app-header")).not.toBeNull();
});
});

View File

@@ -0,0 +1,225 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MediaPlayer } from "../../components/MediaPlayer.tsx";
import FilePreview from "../../components/FilePreview.tsx";
import { makeDump, makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
function media(container: HTMLElement) {
return container.querySelector<HTMLMediaElement>("audio, video")!;
}
function setTime(el: HTMLMediaElement, currentTime: number, duration: number) {
Object.defineProperty(el, "duration", { configurable: true, value: duration });
el.currentTime = currentTime;
fireEvent(el, new Event("durationchange"));
fireEvent(el, new Event("timeupdate"));
}
describe("MediaPlayer", () => {
test("play and pause through the button, reporting state", async () => {
const onPlayStateChange = vi.fn();
const play = vi.spyOn(HTMLMediaElement.prototype, "play");
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause");
renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalled();
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(pause).toHaveBeenCalled();
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
});
test("autoplays when asked", async () => {
const onPlayStateChange = vi.fn();
renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay onPlayStateChange={onPlayStateChange} />,
);
await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true));
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
});
test("a play refused by the browser stays paused", async () => {
vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(new Error("NotAllowedError"));
renderWithContexts(<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay />);
await act(async () => {});
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
});
test("a pause from outside the page (a media key) is reflected", async () => {
const onPlayStateChange = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Play" }));
fireEvent(media(container), new Event("pause"));
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
fireEvent(media(container), new Event("play"));
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
});
test("time, duration, seeking and the end", async () => {
const onTimeUpdate = vi.fn();
const onEnded = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
/>,
);
const el = media(container);
setTime(el, 65, 200);
expect(screen.getByText("1:05")).toBeInTheDocument();
expect(screen.getByText("3:20")).toBeInTheDocument();
expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200);
fireEvent.change(screen.getByRole("slider", { name: "Seek" }), { target: { value: "120" } });
expect(el.currentTime).toBe(120);
fireEvent(el, new Event("ended"));
expect(onEnded).toHaveBeenCalled();
});
test("while the seek bar is held, playback doesn't move the thumb — by touch too", () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
);
const el = media(container);
setTime(el, 10, 100);
const seek = screen.getByRole("slider", { name: "Seek" });
fireEvent.pointerDown(seek, { pointerType: "touch" });
fireEvent.change(seek, { target: { value: "50" } });
setTime(el, 11, 100);
expect(screen.getByText("0:50")).toBeInTheDocument();
fireEvent.pointerUp(seek, { pointerType: "touch" });
setTime(el, 51, 100);
expect(screen.getByText("0:51")).toBeInTheDocument();
});
test("an error is reported for re-resolving", () => {
const onError = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onError={onError} />,
);
fireEvent(media(container), new Event("error"));
expect(onError).toHaveBeenCalled();
});
test("volume and mute are remembered", async () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
);
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.4" } });
expect(media(container).volume).toBeCloseTo(0.4);
await userEvent.click(screen.getByRole("button", { name: "Mute" }));
expect(media(container).muted).toBe(true);
expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({ volume: 0.4, muted: true });
expect(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0");
// Raising the volume unmutes.
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.7" } });
expect(media(container).muted).toBe(false);
});
test("registers seek and toggle handles for the global player", async () => {
const seekRef = { current: null as ((t: number) => void) | null };
const toggleRef = { current: null as (() => void) | null };
const { container, unmount } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" seekRef={seekRef} toggleRef={toggleRef} />,
);
act(() => seekRef.current!(33));
expect(media(container).currentTime).toBe(33);
await act(async () => toggleRef.current!());
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
unmount();
expect(seekRef.current).toBeNull();
});
test("resumes at a start offset once metadata is in", () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" startTime={42} />,
);
fireEvent(media(container), new Event("loadedmetadata"));
expect(media(container).currentTime).toBe(42);
});
test("video has a fullscreen button", () => {
renderWithContexts(<MediaPlayer src="https://s/a.mp4" kind="video" />);
expect(screen.getByRole("button", { name: "Fullscreen" })).toBeInTheDocument();
});
});
describe("FilePreview", () => {
const session = makeSession();
const file = (mime: string, over = {}) =>
makeDump({ id: "f1", kind: "file", fileName: "thing", fileMime: mime, fileSize: 2048, url: undefined, ...over });
test("images show inline, with the media token", () => {
renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
"src",
`http://localhost:8000/api/files/f1?v=2048&token=${session.mediaToken}`,
);
});
test("other files are a download link", () => {
renderWithContexts(<FilePreview dump={file("application/zip")} />);
const link = screen.getByRole("link", { name: /Download thing/ });
expect(link).toHaveAttribute("download", "thing");
expect(link).toHaveTextContent("(2.0 KB)");
});
test("compact audio plays in the global player", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "file",
mimeType: "audio/mpeg",
title: "Dump f1",
subtitle: "thing",
dumpHref: "/dumps/f1",
artworkUrl: undefined,
}));
});
test("compact video claims its generated still as artwork", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play.mock.calls[0][0].artworkUrl).toBe("http://localhost:8000/api/thumbnails/f1");
});
test("the global video button toggles when it's the one playing", async () => {
const togglePlay = vi.fn();
const d = file("video/mp4");
renderWithContexts(<FilePreview dump={d} global />, {
player: {
togglePlay,
playing: true,
current: { kind: "file", fileUrl: "http://localhost:8000/api/files/f1?v=2048", mimeType: "video/mp4" },
},
});
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(togglePlay).toHaveBeenCalled();
});
test("the global audio waveform starts playback", async () => {
const play = vi.fn();
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }));
});
test("compact PDFs and text get an icon", () => {
const { container } = renderWithContexts(<FilePreview dump={file("application/pdf")} compact />);
expect(container).toHaveTextContent("📄");
});
});
import { route } from "../fetch.ts";

View File

@@ -0,0 +1,242 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useContext } from "react";
import { GlobalPlayer } from "../../components/GlobalPlayer.tsx";
import { JournalCard } from "../../components/JournalCard.tsx";
import { PlaylistCard } from "../../components/PlaylistCard.tsx";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
import { markPlaylistVisited } from "../../utils/visited.ts";
let player!: React.ContextType<typeof PlayerContext>;
function Probe() {
player = useContext(PlayerContext);
return null;
}
function renderPlayer() {
return renderWithContexts(
<PlayerProvider>
<Probe />
<GlobalPlayer />
</PlayerProvider>,
);
}
const track = (i: number): PlayerItem => ({
kind: "stream",
type: "bandcamp",
streamUrl: `https://s/${i}`,
resolveUrl: "https://a.bandcamp.com/album/x",
resolveIndex: i,
resolvedAt: Date.now(),
title: `Song ${i}`,
trackNum: i + 1,
duration: 61,
subtitle: "Band",
dumpHref: "/dumps/album",
});
describe("GlobalPlayer", () => {
test("hidden until something plays; the body makes room for it", () => {
renderPlayer();
expect(document.querySelector(".global-player")).toBeNull();
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=x", type: "soundcloud", title: "Mix" }));
expect(document.querySelector(".global-player--soundcloud")).not.toBeNull();
expect(document.body).toHaveClass("has-player");
act(() => player.stop());
expect(document.querySelector(".global-player")).toBeNull();
expect(document.body).not.toHaveClass("has-player");
});
test("an embed autoplays on a fresh play only", () => {
localStorage.setItem("player", JSON.stringify({
v: 2,
queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }],
index: 0,
time: 0,
owner: null,
}));
renderPlayer();
const iframe = () => document.querySelector("iframe")!;
expect(iframe().src).toBe("https://www.youtube.com/embed/abc");
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/def", type: "youtube" }));
expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1");
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=y", type: "soundcloud" }));
expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true");
act(() => player.play({ kind: "embed", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", type: "bandcamp" }));
expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1");
});
test("a YouTube playlist embed gets the JS API parameters", () => {
renderPlayer();
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", type: "youtube" }));
const src = new URL(document.querySelector("iframe")!.src);
expect(src.searchParams.get("enablejsapi")).toBe("1");
expect(src.searchParams.get("list")).toBe("PL");
});
test("a queue: title links to the dump, tracklist, transport", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.playQueue([track(0), track(1), track(2)], 0));
expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute("href", "/dumps/album");
expect(screen.getByText("Band")).toBeInTheDocument();
expect(screen.getByText("1 / 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled();
const list = screen.getByRole("list");
expect(within(list).getAllByRole("button")).toHaveLength(3);
expect(within(list).getAllByText("1:01")).toHaveLength(3);
await user.click(screen.getByRole("button", { name: "Next track" }));
expect(screen.getByText("2 / 3")).toBeInTheDocument();
await user.click(within(list).getByRole("button", { name: /Song 2/ }));
expect(screen.getByText("3 / 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next track" })).toBeDisabled();
expect(list.querySelectorAll(".is-current")).toHaveLength(1);
});
test("collapse hides the tracklist; a new item opens it again", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.playQueue([track(0), track(1)]));
await user.click(screen.getByRole("button", { name: "Collapse player" }));
expect(screen.queryByRole("list")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand player" })).toBeInTheDocument();
act(() => player.playQueue([track(2), track(3)]));
expect(screen.getByRole("list")).toBeInTheDocument();
});
test("the close button stops playback", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.play(track(0)));
await user.click(screen.getByRole("button", { name: "Close player" }));
expect(player.current).toBeNull();
});
test("a file plays in the built-in player", () => {
renderPlayer();
act(() => player.play({ kind: "file", fileUrl: "http://localhost:8000/api/files/x", mimeType: "video/mp4", title: "Clip" }));
expect(document.querySelector(".global-player--file-video video")).not.toBeNull();
expect(screen.getByText("Clip")).toBeInTheDocument();
});
test("the resolving notice", async () => {
renderPlayer();
act(() => player.play(track(0)));
// Force a re-resolve that never lands.
const { route, deferred } = await import("../fetch.ts");
const pending = deferred();
route("GET /api/bandcamp/tracks", () => pending.promise);
act(() => player.onError());
expect(screen.getByText("Refreshing stream…")).toBeInTheDocument();
});
});
describe("JournalCard", () => {
const base = { voteCount: 1, voted: false, canVote: true, castVote: vi.fn(), removeVote: vi.fn() };
test("image, quote and text modes", () => {
const { container, unmount } = renderWithContexts(
<ul>
<JournalCard
dump={makeDump({ richContent: { type: "link", url: "u", thumbnailUrl: "https://i.test/a.png" } })}
shape="feature"
{...base}
/>
<JournalCard dump={makeDump({ comment: "A thought" })} shape="square" {...base} />
<JournalCard dump={makeDump()} shape="square" {...base} />
</ul>,
);
const cards = container.querySelectorAll("li");
expect(cards[0]).toHaveClass("journal-card--image", "journal-card--feature");
expect(cards[1]).toHaveClass("journal-card--quote");
expect(within(cards[1] as HTMLElement).getByText("A thought")).toBeInTheDocument();
expect(cards[2]).toHaveClass("journal-card--text");
expect(cards[2]).toHaveTextContent("🔗");
unmount();
});
test("a playable image card plays; an unplayable one navigates", async () => {
const play = vi.fn();
const user = userEvent.setup();
const yt = makeDump({
id: "y",
richContent: {
type: "youtube",
url: "https://youtu.be/y",
thumbnailUrl: "https://i.test/y.png",
embedUrl: "https://www.youtube.com/embed/y",
},
});
const pic = makeDump({ id: "p", kind: "file", fileMime: "image/png", fileName: "p.png", url: undefined });
const { container } = renderWithContexts(
<ul>
<JournalCard dump={yt} shape="wide" {...base} />
<JournalCard dump={pic} shape="square" {...base} />
</ul>,
{ player: { play } },
);
const cards = container.querySelectorAll("li");
await user.click(cards[0].querySelector(".journal-card-image")!);
expect(play).toHaveBeenCalledWith(expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }));
expect(currentPath()).toBe("/");
await user.click(cards[1].querySelector(".journal-card-image")!);
expect(currentPath()).toBe("/dumps/p");
});
test("a video file shows its generated still", () => {
const { container } = renderWithContexts(
<ul>
<JournalCard dump={makeDump({ id: "v", kind: "file", fileMime: "video/mp4", url: undefined })} shape="tall" {...base} />
</ul>,
);
expect(container.querySelector("img")!.getAttribute("src")).toBe("http://localhost:8000/api/thumbnails/v");
});
});
describe("PlaylistCard", () => {
test("title, badge, owner, count; clicking opens it and marks it seen", async () => {
const user = userEvent.setup();
const p = makePlaylist({ id: "p1", slug: "mix", title: "Mix", ownerUsername: "ann", dumpCount: 1, isPublic: false, description: "Tunes" });
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute("href", "/playlists/mix");
expect(screen.getByText("private")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByText("1 dump")).toBeInTheDocument();
expect(screen.getByText("Tunes")).toBeInTheDocument();
await user.click(container.querySelector(".playlist-card-inner")!);
expect(currentPath()).toBe("/playlists/mix");
});
test("the owner doesn't see their own name, or an unread dot", () => {
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} isOwner /></ul>);
expect(screen.queryByText("@me")).not.toBeInTheDocument();
expect(container.querySelector(".unread-dot")).toBeNull();
});
test("a new playlist has a dot until visited", () => {
const p = makePlaylist({ id: "fresh", createdAt: new Date() });
const first = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount();
markPlaylistVisited("fresh");
const again = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(again.container.querySelector(".unread-dot")).toBeNull();
});
test("delete doesn't open the playlist", async () => {
const onDelete = vi.fn();
const user = userEvent.setup();
renderWithContexts(<ul><PlaylistCard playlist={makePlaylist()} onDelete={onDelete} /></ul>);
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
expect(onDelete).toHaveBeenCalled();
expect(currentPath()).toBe("/");
});
});

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);
});
});

11
src/test/emojibase.ts Normal file
View File

@@ -0,0 +1,11 @@
import { route } from "./fetch.ts";
import enData from "emojibase-data/en/data.json";
import enMessages from "emojibase-data/en/messages.json";
/** Serves the emoji picker's data as the app does, from /emojibase/<ver>/. */
export function serveEmojibase() {
route("GET /emojibase/:version/en/data.json", () => new Response(JSON.stringify(enData)));
route("GET /emojibase/:version/en/messages.json", () => new Response(JSON.stringify(enMessages)));
route("HEAD /emojibase/:version/en/data.json", () => new Response(null, { headers: { ETag: "x" } }));
route("HEAD /emojibase/:version/en/messages.json", () => new Response(null, { headers: { ETag: "y" } }));
}

159
src/test/fetch.ts Normal file
View File

@@ -0,0 +1,159 @@
import { vi } from "vitest";
/**
* A routing fetch stub. Tests register handlers by method and path pattern
* (`:param` segments match one segment); every request is recorded, and one no
* handler matches is answered 404 so a missing route shows up as a failure in
* the UI rather than a hang.
*/
export interface RecordedRequest {
method: string;
url: URL;
path: string;
headers: Headers;
body: unknown;
params: Record<string, string>;
}
type Handler = (
req: RecordedRequest,
) => Response | Promise<Response> | unknown;
interface Route {
method: string;
pattern: RegExp;
keys: string[];
handler: Handler;
once: boolean;
}
let routes: Route[] = [];
export let requests: RecordedRequest[] = [];
function compile(path: string) {
const keys: string[] = [];
const src = path.replace(/:(\w+)/g, (_, k) => {
keys.push(k);
return "([^/]+)";
});
return { pattern: new RegExp(`^${src}$`), keys };
}
/** A success envelope, as every API route returns. */
export function ok(data: unknown, status = 200): Response {
return new Response(JSON.stringify({ success: true, data }), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** An error envelope. */
export function fail(message: string, status = 400): Response {
return new Response(
JSON.stringify({ success: false, error: { message } }),
{ status, headers: { "Content-Type": "application/json" } },
);
}
/**
* Registers a handler. `spec` is "METHOD /path/:param". A handler may return a
* Response, or plain data to be wrapped in a success envelope.
*
* Standing routes match newest first, so a test can override a default.
* `once` routes answer a single request each, in the order they were added,
* ahead of any standing route — a queue of responses for a sequence of calls.
*/
export function route(
spec: string,
handler: Handler,
{ once = false, fallback = false } = {},
) {
const [method, path] = spec.split(" ");
const { pattern, keys } = compile(path);
const r = { method, pattern, keys, handler, once };
// A fallback answers only when nothing else does, whenever it was added —
// for defaults that a test may have overridden before they were installed.
if (fallback) {
routes.push(r);
return;
}
// Both kinds go in just before the first standing route: after the queued
// once-routes, ahead of older standing ones.
const at = routes.findIndex((x) => !x.once);
routes.splice(at === -1 ? routes.length : at, 0, r);
}
export function resetFetch() {
routes = [];
requests = [];
vi.stubGlobal("fetch", fetchStub);
}
/** Requests made to `spec` ("METHOD /path"), pattern-matched like routes. */
export function calls(spec: string): RecordedRequest[] {
const [method, path] = spec.split(" ");
const { pattern } = compile(path);
return requests.filter((r) => r.method === method && pattern.test(r.path));
}
async function readBody(body: BodyInit | null | undefined): Promise<unknown> {
if (body == null) return undefined;
if (typeof body === "string") {
try {
return JSON.parse(body);
} catch {
return body;
}
}
return body; // FormData and friends, as passed
}
async function fetchStub(
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<Response> {
const href = typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const url = new URL(href, location.href);
const method = (init.method ?? "GET").toUpperCase();
if (init.signal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
const req: RecordedRequest = {
method,
url,
path: url.pathname,
headers: new Headers(init.headers),
body: await readBody(init.body),
params: {},
};
requests.push(req);
for (const r of routes) {
if (r.method !== method) continue;
const m = r.pattern.exec(url.pathname);
if (!m) continue;
r.keys.forEach((k, i) => (req.params[k] = decodeURIComponent(m[i + 1])));
if (r.once) routes = routes.filter((x) => x !== r);
const result = await r.handler(req);
if (result instanceof Response) return result;
if (result instanceof Error) throw result;
return ok(result);
}
return fail(`No test route for ${method} ${url.pathname}`, 404);
}
/** A promise with its resolver exposed, for holding a response back. */
export function deferred<T = Response>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

144
src/test/fixtures.ts Normal file
View File

@@ -0,0 +1,144 @@
import type {
AuthResponse,
Category,
ChatMessage,
Comment,
Dump,
Notification,
Playlist,
PublicUser,
} from "../model.ts";
import { AUTH_STORAGE_KEY } from "../utils/authStorage.ts";
let seq = 0;
const nextId = (prefix: string) => `${prefix}-${++seq}`;
/** An unsigned JWT-shaped token; the client only ever reads `exp`. */
export function makeToken(
{ expiresIn = 3600, sub = "u" }: { expiresIn?: number; sub?: string } = {},
): string {
const b64 = (o: unknown) =>
btoa(JSON.stringify(o)).replace(/=+$/, "").replace(/\+/g, "-").replace(
/\//g,
"_",
);
const exp = Math.floor(Date.now() / 1000) + expiresIn;
return `${b64({ alg: "HS256" })}.${b64({ sub, exp, n: ++seq })}.sig`;
}
export function makeUser(over: Partial<PublicUser> = {}): PublicUser {
const id = over.id ?? nextId("user");
return {
id,
username: over.username ?? `user${seq}`,
role: "user",
createdAt: new Date("2026-01-01T00:00:00Z"),
...over,
};
}
export function makeSession(over: Partial<PublicUser> = {}): AuthResponse {
const user = makeUser(over);
return {
token: makeToken({ sub: user.id }),
mediaToken: `media-${user.id}`,
user,
};
}
/** Puts a session in storage, where AuthProvider picks it up on mount. */
export function storeSession(session: AuthResponse) {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
}
export function makeDump(over: Partial<Dump> = {}): Dump {
const id = over.id ?? nextId("dump");
return {
id,
kind: "url",
title: `Dump ${id}`,
userId: "user-owner",
createdAt: new Date(Date.now() - 60_000),
url: `https://example.com/${id}`,
voteCount: 0,
commentCount: 0,
isPrivate: false,
...over,
};
}
export function makePlaylist(over: Partial<Playlist> = {}): Playlist {
const id = over.id ?? nextId("playlist");
return {
id,
userId: "user-owner",
title: `Playlist ${id}`,
isPublic: true,
createdAt: new Date("2026-02-01T00:00:00Z"),
dumpCount: 0,
...over,
};
}
export function makeComment(over: Partial<Comment> = {}): Comment {
const id = over.id ?? nextId("comment");
return {
id,
dumpId: "dump-x",
userId: "user-owner",
body: `Comment ${id}`,
createdAt: new Date(Date.now() - 30_000),
deleted: false,
likeCount: 0,
authorUsername: "owner",
...over,
};
}
export function makeChatMessage(over: Partial<ChatMessage> = {}): ChatMessage {
const id = over.id ?? nextId("msg");
return {
id,
userId: "user-other",
body: `Message ${id}`,
createdAt: new Date(Date.now() - 10_000),
authorUsername: "other",
...over,
};
}
export function makeCategory(over: Partial<Category> = {}): Category {
const id = over.id ?? nextId("cat");
return {
id,
slug: over.slug ?? `cat${seq}`,
name: over.name ?? `Category ${seq}`,
position: 0,
createdAt: new Date("2026-01-01T00:00:00Z"),
...over,
};
}
export function makeNotification(
over: Partial<Notification> = {},
): Notification {
return {
id: over.id ?? nextId("notif"),
userId: "user-me",
type: "user_followed",
data: { followerId: "user-f", followerUsername: "fan" },
read: false,
createdAt: new Date(Date.now() - 5_000),
...over,
};
}
/** A value as it crosses the wire: dates become ISO strings. */
export function wire<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
/** A paginated envelope payload. */
export function page<T>(items: T[], hasMore = false) {
return { items: wire(items), total: items.length, hasMore };
}

View File

@@ -0,0 +1,321 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useRef, useState } from "react";
import { MemoryRouter } from "react-router";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { useUserDumpFeed } from "../../hooks/useUserDumpFeed.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { FakeIntersectionObserver } from "../setup.ts";
afterEach(() => {
vi.unstubAllGlobals();
document.head.querySelectorAll("script").forEach((s) => s.remove());
});
describe("usePlayRichContent", () => {
// The Bandcamp mode is read from a <meta> tag once, at module load.
async function load(mode: "embed" | "native") {
document.head.innerHTML = `<meta name="bandcamp-player" content="${mode}">`;
vi.resetModules();
const hookMod = await import("../../hooks/usePlayRichContent.ts");
const ctxMod = await import("../../contexts/PlayerContext.ts");
const play = vi.fn();
const playQueue = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<ctxMod.PlayerContext.Provider
value={{
...(ctxMod.PlayerContext as unknown as { _currentValue: object })._currentValue,
play,
playQueue,
} as never}
>
{children}
</ctxMod.PlayerContext.Provider>
);
const hook = renderHook(hookMod.usePlayRichContent, { wrapper });
return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent };
}
afterEach(() => {
document.head.innerHTML = "";
});
const yt = {
type: "youtube",
url: "https://youtu.be/x",
title: "Vid",
embedUrl: "https://www.youtube.com/embed/x",
thumbnailUrl: "https://i.ytimg.com/x.jpg",
siteName: "YouTube",
};
const bc = {
type: "bandcamp",
url: "https://a.bandcamp.com/album/x",
title: "Album",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
};
test("embeds play in the iframe", async () => {
const { hook, play } = await load("embed");
let started!: boolean;
await act(async () => {
started = await hook.result.current.playRichContent(yt, "/dumps/1");
});
expect(started).toBe(true);
expect(play).toHaveBeenCalledWith({
kind: "embed",
embedUrl: yt.embedUrl,
title: "Vid",
type: "youtube",
dumpHref: "/dumps/1",
artworkUrl: yt.thumbnailUrl,
subtitle: "YouTube",
});
});
test("nothing to embed: not started", async () => {
const { hook, play, canPlay } = await load("embed");
const bare = { type: "link", url: "https://x" };
expect(canPlay(bare)).toBe(false);
let started = true;
await act(async () => {
started = await hook.result.current.playRichContent(bare);
});
expect(started).toBe(false);
expect(play).not.toHaveBeenCalled();
});
test("native Bandcamp plays a resolved queue", async () => {
route("GET /api/bandcamp/tracks", () => ({
sourceUrl: bc.url,
itemType: "album",
resolvedAt: 1,
tracks: [{ index: 0, title: "T", streamUrl: "https://s/0", streamable: true, capped: false }],
}));
const { hook, playQueue, canPlay } = await load("native");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).toHaveBeenCalledWith(
[expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })],
0,
);
expect(hook.result.current.pending).toBe(false);
});
test("native Bandcamp that can't resolve falls back to the embed", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
route("GET /api/bandcamp/tracks", () => fail("x", 500));
const { hook, play, playQueue } = await load("native");
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).not.toHaveBeenCalled();
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "embed" }));
});
test("an unknown mode reads as embed", async () => {
const { canPlay } = await load("bogus" as "embed");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false);
});
});
describe("useUserDumpFeed", () => {
const ann = makeUser({ username: "ann" });
function setup(opts: { session?: ReturnType<typeof makeSession> | null } = {}) {
const session = opts.session ?? null;
function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(session);
return (
<MemoryRouter>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
</MemoryRouter>
);
}
return renderHook(
({ username }: { username?: string }) =>
useUserDumpFeed(username, "dumps", "cache-key"),
{
initialProps: { username: "ann" as string | undefined },
wrapper: Wrapper,
},
);
}
test("loads the profile and the first page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
const session = makeSession();
const { result } = setup({ session });
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
expect(s.profileUser.username).toBe("ann");
expect(s.items[0].createdAt).toBeInstanceOf(Date);
expect(s.hasMore).toBe(true);
const [req] = calls("GET /api/users/:u/dumps");
expect(req.url.searchParams.get("page")).toBe("1");
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("an unknown user is an error", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("error"));
});
test("an offline load says so", async () => {
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
}));
});
test("scrolling to the sentinel loads the next page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const sentinel = document.createElement("div");
act(() => (result.current.sentinelRef as (el: HTMLDivElement) => void)(sentinel));
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
await waitFor(() => {
const s = result.current.state;
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual(["a", "b"]);
});
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
expect(s.hasMore).toBe(false);
expect(s.page).toBe(2);
});
test("changing user resets to loading", async () => {
route("GET /api/users/:u", (req) => wire(makeUser({ username: req.params.u })));
route("GET /api/users/:u/dumps", () => page([]));
const { result, rerender } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
rerender({ username: "bob" });
expect(result.current.state.status).toBe("loading");
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
});
test("setItems edits the loaded list", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" }), makeDump({ id: "b" })]));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.setItems((prev) => prev.slice(1)));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
expect(s.items.map((d) => d.id)).toEqual(["b"]);
});
});
describe("useYouTubePlaylist", () => {
type Events = { onReady?: () => void; onStateChange?: (e: { data: number }) => void };
function installYT(ids = ["a", "b", "c"]) {
const player = {
events: {} as Events,
index: 0,
playVideo: vi.fn(),
playVideoAt: vi.fn(),
nextVideo: vi.fn(),
previousVideo: vi.fn(),
getPlaylist: () => ids,
getPlaylistIndex() {
return this.index;
},
destroy: vi.fn(),
};
(globalThis as unknown as { YT: unknown }).YT = {
Player: class {
constructor(_el: unknown, opts: { events: Events }) {
player.events = opts.events;
return player;
}
},
};
return player;
}
// The IFrame API loads once per document and is cached in the module.
async function setup(embedUrl: string | null) {
vi.resetModules();
const { useYouTubePlaylist } = await import("../../hooks/useYouTubePlaylist.ts");
return renderHook(({ url }) => {
const ref = useRef<HTMLIFrameElement | null>(document.createElement("iframe"));
return useYouTubePlaylist(url, ref);
}, { initialProps: { url: embedUrl } });
}
test("titles from the server, order and position from the player", async () => {
// The API script loads once per document; later tests find YT ready.
const player = installYT();
route("GET /api/youtube/playlist", () => ({
tracks: [
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "c", title: "C", duration: 30 },
],
}));
const { result } = await setup("https://www.youtube.com/embed/videoseries?list=PL1");
await waitFor(() => expect(result.current.rows).toHaveLength(2));
expect(result.current.ready).toBe(false);
await waitFor(() => expect(player.events.onReady).toBeDefined());
player.index = 1;
act(() => player.events.onReady!());
expect(result.current.ready).toBe(true);
expect(result.current.index).toBe(1);
// The player's list wins, matched to the titles by id.
expect(result.current.rows).toEqual([
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "b", title: undefined, duration: undefined },
{ videoId: "c", title: "C", duration: 30 },
]);
act(() => result.current.playAt(2));
act(() => result.current.next());
act(() => result.current.previous());
expect(player.playVideoAt).toHaveBeenCalledWith(2);
expect(player.nextVideo).toHaveBeenCalled();
expect(player.previousVideo).toHaveBeenCalled();
});
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
await setup("https://www.youtube.com/embed/x?list=PL2");
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
document.dispatchEvent(new Event("visibilitychange"));
for (let i = 0; i < 5; i++) act(() => player.events.onStateChange!({ data: 2 }));
expect(player.playVideo).toHaveBeenCalledTimes(3);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
act(() => player.events.onStateChange!({ data: 2 }));
expect(player.playVideo).toHaveBeenCalledTimes(3);
});
test("no list in the URL: no rows, no requests", async () => {
const { result } = await setup("https://www.youtube.com/embed/x");
expect(result.current.rows).toEqual([]);
expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
});
test("a new embed drops the old list; unmount destroys the player", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
const { result, rerender, unmount } = await setup("https://www.youtube.com/embed/x?list=PL3");
await waitFor(() => expect(result.current.rows).toHaveLength(1));
await waitFor(() => expect(player.events.onReady).toBeDefined());
route("GET /api/youtube/playlist", () => fail("x", 500));
rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
expect(result.current.rows).toEqual([]);
expect(result.current.index).toBe(-1);
unmount();
expect(player.destroy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,271 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
import { calls, deferred, route } from "../fetch.ts";
function key(k: string, mods: Partial<KeyboardEvent> = {}) {
return {
key: k,
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...mods,
} as unknown as KeyboardEvent<HTMLTextAreaElement>;
}
describe("useEmojiTrigger", () => {
function setup() {
const replace = vi.fn();
const hook = renderHook(() => useEmojiTrigger(replace));
const type = (value: string, cursor = value.length) =>
act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
return { replace, hook, type };
}
test("opens inline on ':' and two letters", () => {
const { hook, type } = setup();
type("hello :s");
expect(hook.result.current.emojiMode).toBe("closed");
type("hello :sm");
expect(hook.result.current.emojiMode).toBe("inline");
expect(hook.result.current.emojiQuery).toBe("sm");
});
test("emoticons, times and labels don't trigger", () => {
const { hook, type } = setup();
for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
type(text);
expect(hook.result.current.emojiMode, text).toBe("closed");
}
});
test("Unicode names work", () => {
const { hook, type } = setup();
type(":cœur");
expect(hook.result.current.emojiQuery).toBe("cœur");
});
test("not inside code", () => {
const { hook, type } = setup();
type("`:smile");
expect(hook.result.current.emojiMode).toBe("closed");
});
test("closes when the caret leaves the query", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("a caret move alone never opens it", () => {
const { hook } = setup();
act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("an inline pick replaces the query and adds a space", () => {
const { hook, type, replace } = setup();
type("hi :smi");
act(() => hook.result.current.selectEmoji("😄"));
expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
expect(hook.result.current.emojiMode).toBe("closed");
});
test("browse mode inserts at the caret and ignores typing", () => {
const { hook, type, replace } = setup();
act(() => hook.result.current.openEmojiBrowser(2, 4));
type("ab");
expect(hook.result.current.emojiMode).toBe("browse");
act(() => hook.result.current.setEmojiQuery("cat"));
expect(hook.result.current.emojiQuery).toBe("cat");
act(() => hook.result.current.selectEmoji("🐱"));
expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
});
test("selecting or querying while closed does nothing", () => {
const { hook, replace } = setup();
act(() => hook.result.current.setEmojiQuery("x"));
act(() => hook.result.current.selectEmoji("😀"));
expect(replace).not.toHaveBeenCalled();
expect(hook.result.current.emojiQuery).toBe("");
});
test("closeEmoji", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.closeEmoji());
expect(hook.result.current.emojiMode).toBe("closed");
});
});
describe("useMentionAutocomplete", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const users = [
{ id: "1", username: "alice", avatarMime: null },
{ id: "2", username: "alex", avatarMime: null },
{ id: "3", username: "albert", avatarMime: null },
];
function setup() {
const replace = vi.fn();
const hook = renderHook(() => useMentionAutocomplete(replace));
const type = async (value: string, cursor = value.length) => {
act(() => hook.result.current.syncMention(value, cursor, true));
await act(() => vi.advanceTimersByTimeAsync(200));
};
return { replace, hook, type };
}
test("looks up the @query after a pause and opens with results", async () => {
route("GET /api/users/search", (req) =>
users.filter((u) => u.username.startsWith(req.url.searchParams.get("q")!)));
const { hook, type } = setup();
await type("hi @al");
expect(hook.result.current.mentionOpen).toBe(true);
expect(hook.result.current.mentionResults).toHaveLength(3);
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe("al");
});
test("debounces keystrokes into one lookup", async () => {
route("GET /api/users/search", () => users);
const { hook } = setup();
act(() => hook.result.current.syncMention("@a", 2, true));
act(() => hook.result.current.syncMention("@al", 3, true));
act(() => hook.result.current.syncMention("@ali", 4, true));
await act(() => vi.advanceTimersByTimeAsync(200));
expect(calls("GET /api/users/search")).toHaveLength(1);
});
test("no results keeps it closed", async () => {
route("GET /api/users/search", () => []);
const { hook, type } = setup();
await type("@zz");
expect(hook.result.current.mentionOpen).toBe(false);
});
test("an email address or a link label isn't a mention", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("me@al");
await type("[@al");
await type("`@al");
expect(calls("GET /api/users/search")).toHaveLength(0);
expect(hook.result.current.mentionOpen).toBe(false);
});
test("a stale lookup can't open over a newer query", async () => {
const slow = deferred();
route("GET /api/users/search", () => slow.promise, { once: true });
route("GET /api/users/search", () => []);
const { hook, type } = setup();
await type("@al");
await type("@alz");
await act(async () => slow.resolve(new Response(JSON.stringify({ success: true, data: users }))));
expect(hook.result.current.mentionOpen).toBe(false);
});
test("arrows wrap, Enter picks, Tab picks", async () => {
route("GET /api/users/search", () => users);
const { hook, type, replace } = setup();
await type("hey @al");
const down = key("ArrowDown");
act(() => {
hook.result.current.handleMentionKeyDown(key("ArrowUp"));
});
expect(hook.result.current.mentionSelectedIndex).toBe(2);
act(() => {
hook.result.current.handleMentionKeyDown(down);
});
expect(down.preventDefault).toHaveBeenCalled();
expect(hook.result.current.mentionSelectedIndex).toBe(0);
act(() => {
hook.result.current.handleMentionKeyDown(key("ArrowDown"));
});
let handled = false;
act(() => {
handled = hook.result.current.handleMentionKeyDown(key("Enter"));
});
expect(handled).toBe(true);
expect(replace).toHaveBeenCalledWith(4, 7, "@alex", true);
expect(hook.result.current.mentionOpen).toBe(false);
await type("@al");
act(() => {
hook.result.current.handleMentionKeyDown(key("Tab"));
});
expect(replace).toHaveBeenLastCalledWith(0, 3, "@alice", true);
});
test("Shift+Enter is left to the editor", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al");
let handled = true;
act(() => {
handled = hook.result.current.handleMentionKeyDown(
key("Enter", { shiftKey: true }),
);
});
expect(handled).toBe(false);
});
test("Escape closes just the list and stops there", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al");
const esc = key("Escape");
act(() => {
hook.result.current.handleMentionKeyDown(esc);
});
expect(esc.stopPropagation).toHaveBeenCalled();
expect(hook.result.current.mentionOpen).toBe(false);
});
test("keys pass through while closed", () => {
const { hook } = setup();
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(false);
});
test("moving the caret off the mention closes it", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al and more");
// typed at position 3
await type("@al and more", 3);
expect(hook.result.current.mentionOpen).toBe(true);
act(() => hook.result.current.syncMention("@al and more", 12, false));
expect(hook.result.current.mentionOpen).toBe(false);
});
test("a caret move within the same query keeps it open", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al");
act(() => hook.result.current.syncMention("@al", 3, false));
expect(hook.result.current.mentionOpen).toBe(true);
});
test("a failed lookup is quiet", async () => {
route("GET /api/users/search", () => new TypeError("offline"));
const { hook, type } = setup();
await type("@al");
expect(hook.result.current.mentionOpen).toBe(false);
});
test("unmounting cancels a pending lookup", async () => {
route("GET /api/users/search", () => users);
const { hook } = setup();
act(() => hook.result.current.syncMention("@al", 3, true));
hook.unmount();
await vi.advanceTimersByTimeAsync(500);
expect(calls("GET /api/users/search")).toHaveLength(0);
});
});

View File

@@ -0,0 +1,452 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { type ReactNode, useRef } from "react";
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router";
import { useFading } from "../../hooks/useFading.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useTabParam } from "../../hooks/useTabParam.ts";
import {
setTitleBadge,
SITE_NAME,
useDocumentTitle,
} from "../../hooks/useDocumentTitle.ts";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
import { useScrollLock } from "../../hooks/useScrollLock.ts";
import { useScrollSave } from "../../hooks/useScrollSave.ts";
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts";
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
import { isDumpVisited } from "../../utils/visited.ts";
import { FakeIntersectionObserver } from "../setup.ts";
afterEach(() => vi.useRealTimers());
describe("useFading", () => {
beforeEach(() => vi.useFakeTimers());
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
expect(result.current.fading).toEqual({ a: "cooldown" });
act(() => vi.advanceTimersByTime(2000));
expect(result.current.fading).toEqual({ a: "dismissing" });
act(() => vi.advanceTimersByTime(350));
expect(result.current.fading).toEqual({});
});
test("starting twice is one animation", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
expect(result.current.fading).toEqual({ a: "dismissing" });
});
test("cancel during cooldown and during dismissal", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => result.current.cancelFading("a"));
expect(result.current.fading).toEqual({});
act(() => vi.advanceTimersByTime(5000));
expect(result.current.fading).toEqual({});
act(() => result.current.startFading("b"));
act(() => vi.advanceTimersByTime(2100));
act(() => result.current.cancelFading("b"));
expect(result.current.fading).toEqual({});
// Cancelled means it can start over.
act(() => result.current.startFading("b"));
expect(result.current.fading).toEqual({ b: "cooldown" });
});
test("cancelAll", () => {
const { result } = renderHook(useFading);
act(() => {
result.current.startFading("a");
result.current.startFading("b");
});
act(() => result.current.cancelAll());
expect(result.current.fading).toEqual({});
});
});
describe("useFeedCache", () => {
// A MemoryRouter's first entry is a POP, as after Back.
const wrapper = () => ({ children }: { children: ReactNode }) => (
<MemoryRouter>{children}</MemoryRouter>
);
test("restores a saved feed on a back/forward (POP) visit", () => {
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
const second = renderHook(
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
{ wrapper: wrapper() },
);
expect(second.result.current.cached).toMatchObject({
items: [{ n: 10 }],
page: 2,
hasMore: true,
scrollY: 300,
});
});
test("an expired entry is ignored", () => {
vi.useFakeTimers();
const first = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([], 1, false, 0));
vi.advanceTimersByTime(11 * 60 * 1000);
const second = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(second.result.current.cached).toBeNull();
});
test("a fresh (PUSH) visit ignores the cache", () => {
sessionStorage.setItem(
"k",
JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }),
);
function Probe() {
const { cached } = useFeedCache("k", (x) => x);
return <p>{cached ? "cached" : "fresh"}</p>;
}
function Home() {
const navigate = useNavigate();
return <button type="button" onClick={() => navigate("/feed")}>go</button>;
}
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/feed" element={<Probe />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("go"));
expect(screen.getByText("fresh")).toBeInTheDocument();
});
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
sessionStorage.setItem("k", "{");
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(result.current.cached).toBeNull();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
});
});
describe("useTabParam", () => {
const TABS = ["dumps", "playlists", "votes"] as const;
function setup(path: string, state?: unknown) {
let loc!: ReturnType<typeof useLocation>;
const hook = renderHook(() => {
loc = useLocation();
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[{ pathname: path.split("?")[0], search: path.includes("?") ? "?" + path.split("?")[1] : "", state }]}>
{children}
</MemoryRouter>
),
});
return { hook, loc: () => loc };
}
test("reads the tab after /~/", () => {
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe("playlists");
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe("playlists");
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
});
test("the default tab collapses to the base path", () => {
const { hook, loc } = setup("/users/ann/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/users/ann");
act(() => hook.result.current[1]("playlists"));
expect(loc().pathname).toBe("/users/ann/~/playlists");
});
test("at the root the default stays explicit", () => {
const { hook, loc } = setup("/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/~/dumps");
});
test("keeps the query string and the entry's state", () => {
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
act(() => hook.result.current[1]("votes"));
expect(loc().pathname).toBe("/search/~/votes");
expect(loc().search).toBe("?q=cats");
expect(loc().state).toEqual({ back: "/x" });
});
});
describe("useDocumentTitle", () => {
test("composes page, site name and unread badge", () => {
const { rerender } = renderHook(
({ title }: { title: string | null | undefined }) => useDocumentTitle(title),
{ initialProps: { title: "Cats" as string | null | undefined } },
);
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
act(() => setTitleBadge(3));
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
act(() => setTitleBadge(150));
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
rerender({ title: null });
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
act(() => setTitleBadge(0));
expect(document.title).toBe(SITE_NAME);
// undefined leaves it be
rerender({ title: undefined });
expect(document.title).toBe(SITE_NAME);
});
test("the site name defaults to gerbeur when unsubstituted", () => {
expect(SITE_NAME).toBe("gerbeur");
});
});
describe("useInfiniteScroll", () => {
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
const ref = useInfiniteScroll(onMore, enabled);
return <div ref={ref} data-testid="sentinel" />;
}
const intersect = (isIntersecting: boolean) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() =>
obs.callback(
[{ isIntersecting } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
};
test("loads more when the sentinel shows, only while enabled", () => {
const onMore = vi.fn();
const { rerender } = render(<Feed onMore={onMore} enabled />);
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
intersect(false);
expect(onMore).not.toHaveBeenCalled();
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled={false} />);
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
});
});
describe("useScrollLock", () => {
function Lock() {
useScrollLock();
return null;
}
test("counts nested locks", () => {
const root = document.documentElement;
const a = render(<Lock />);
const b = render(<Lock />);
expect(root.style.overflowY).toBe("hidden");
a.unmount();
expect(root.style.overflowY).toBe("hidden");
b.unmount();
expect(root.style.overflowY).toBe("");
});
});
describe("useScrollSave", () => {
test("debounces scroll positions while enabled", () => {
vi.useFakeTimers();
const onSave = vi.fn();
const { rerender, unmount } = renderHook(
({ on }) => useScrollSave(on, onSave),
{ initialProps: { on: true } },
);
globalThis.dispatchEvent(new Event("scroll"));
globalThis.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(100);
expect(onSave).toHaveBeenCalledTimes(1);
rerender({ on: false });
globalThis.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(100);
expect(onSave).toHaveBeenCalledTimes(1);
unmount();
});
});
describe("stored preferences", () => {
test("useDefaultFeedTab validates and persists", () => {
localStorage.setItem("default-feed-tab", "bogus");
const { result } = renderHook(useDefaultFeedTab);
expect(result.current[0]).toBe("journal");
act(() => result.current[1]("new"));
expect(result.current[0]).toBe("new");
expect(localStorage.getItem("default-feed-tab")).toBe("new");
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
});
test("useSoundsEnabled", () => {
const { result } = renderHook(useSoundsEnabled);
expect(result.current[0]).toBe(true);
act(() => result.current[1](false));
expect(result.current[0]).toBe(false);
expect(localStorage.getItem("sounds")).toBe("off");
});
});
describe("useDumpCardLink", () => {
function Card() {
const link = useDumpCardLink({ id: "d1", slug: "hello" });
return (
<div data-testid="card" {...link.cardProps}>
<a href="/elsewhere">inner link</a>
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) (e.currentTarget.dataset.played = "1");
}}
>
play
</button>
</div>
);
}
function setup() {
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Card />} />
<Route path="/dumps/:id" element={<p>dump page</p>} />
</Routes>
</MemoryRouter>,
);
return { open };
}
test("a click navigates and marks visited", () => {
setup();
fireEvent.click(screen.getByTestId("card"));
expect(screen.getByText("dump page")).toBeInTheDocument();
expect(isDumpVisited("d1")).toBe(true);
});
test("ctrl/cmd/shift-click opens a new tab", () => {
const { open } = setup();
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
});
test("a middle click opens a new tab, except over a link", () => {
const { open } = setup();
const mouseDown = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
screen.getByTestId("card").dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
expect(open).toHaveBeenCalledTimes(1);
fireEvent(screen.getByText("inner link"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
expect(open).toHaveBeenCalledTimes(1);
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 2, bubbles: true }));
expect(open).toHaveBeenCalledTimes(1);
});
test("a modified click on the play button opens the dump instead of playing", () => {
const { open } = setup();
const play = screen.getByText("play");
fireEvent.click(play, { metaKey: true });
expect(open).toHaveBeenCalledTimes(1);
expect(play.dataset.played).toBeUndefined();
fireEvent.click(play);
expect(play.dataset.played).toBe("1");
});
});
describe("useAnchoredFloat", () => {
function Float({ open, top }: { open: boolean; top: number }) {
const ref = useRef<HTMLDivElement>(null);
const style = useAnchoredFloat(ref, open, {
width: 300,
chromeHeight: 40,
minContentHeight: 100,
maxContentHeight: 400,
});
return (
<>
<div
ref={(el) => {
ref.current = el;
if (el) {
el.getBoundingClientRect = () =>
({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20 }) as DOMRect;
}
}}
/>
<output data-testid="style">{JSON.stringify(style)}</output>
</>
);
}
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
beforeEach(() => {
vi.stubGlobal("innerWidth", 1024);
vi.stubGlobal("innerHeight", 768);
});
test("null while closed", () => {
render(<Float open={false} top={100} />);
expect(styleOf()).toBeNull();
});
test("opens below an anchor near the top, clamped into the viewport", () => {
render(<Float open top={100} />);
const s = styleOf();
expect(s.position).toBe("fixed");
expect(s.top).toBe(124);
expect(s.bottom).toBe("auto");
expect(s.left).toBe(1024 - 300 - 8);
expect(s["--float-max-h"]).toBe("400px");
});
test("flips above an anchor near the bottom", () => {
render(<Float open top={700} />);
const s = styleOf();
expect(s.bottom).toBe(768 - 700 + 4);
expect(s.top).toBe("auto");
});
});
describe("useHeightTransition", () => {
test("animates from the old height to the new one when watch changes", () => {
const animate = vi.spyOn(Element.prototype, "animate");
let height = 100;
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
() => ({ height }) as DOMRect,
);
function Box({ open }: { open: boolean }) {
const ref = useHeightTransition<HTMLDivElement>(open);
return <div ref={ref} />;
}
const { rerender } = render(<Box open={false} />);
height = 250;
rerender(<Box open />);
expect(animate).toHaveBeenCalledTimes(1);
const [frames] = animate.mock.calls[0] as unknown as [Keyframe[]];
expect(frames[0].height).toBe("100px");
expect(frames[1].height).toBe("250px");
// No change in height, no animation.
rerender(<Box open={false} />);
expect(animate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,238 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import { WSContext, type WSContextValue } from "../../contexts/WSContext.ts";
import type { Dump, Playlist } from "../../model.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
import { defaultOf } from "../render.tsx";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultOf(WSContext);
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return { Wrapper, emit: (patch: Partial<WSContextValue>) => act(() => push(patch)) };
}
function useList<T>(initial: T[]) {
const [items, setItems] = useState(initial);
return { items, setItems };
}
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("useDumpListSync", () => {
function setup(initial: Dump[], opts?: Parameters<typeof useDumpListSync>[1]) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
useDumpListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("drops deleted dumps", () => {
const { hook, emit } = setup([makeDump({ id: "a" }), makeDump({ id: "b" })]);
emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]);
});
test("updates a dump in place", () => {
const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
expect(hook.result.current.items[0].title).toBe("new");
});
test("a dump gone private leaves others' lists but not the owner's", () => {
const a = makeDump({ id: "a" });
const viewer = setup([a]);
viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(viewer.hook.result.current.items).toEqual([]);
const owner = setup([a], { isOwner: true });
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
});
test("re-inserts only the owner's dumps, and only when asked", () => {
const mine = makeDump({ id: "m", userId: "me" });
const theirs = makeDump({ id: "t", userId: "them" });
const none = setup([]);
none.emit({ lastDumpEvent: mine });
expect(none.hook.result.current.items).toEqual([]);
const owned = setup([], { ownerId: "me" });
owned.emit({ lastDumpEvent: theirs });
expect(owned.hook.result.current.items).toEqual([]);
owned.emit({ lastDumpEvent: mine });
expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
const skip = setup([], { ownerId: "me", skipReinsert: true });
skip.emit({ lastDumpEvent: mine });
expect(skip.hook.result.current.items).toEqual([]);
const privateOne = setup([], { ownerId: "me" });
privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
expect(privateOne.hook.result.current.items).toEqual([]);
});
test("comment events move the count, never below zero; edits don't", () => {
const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(0);
emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
expect(hook.result.current.items).toHaveLength(1);
});
});
describe("usePlaylistListSync", () => {
function setup(
initial: Playlist[],
opts?: Parameters<typeof usePlaylistListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
usePlaylistListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const p = makePlaylist({ id: "b" });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p } });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: { ...p } } });
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });
byOwner.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
expect(byOwner.hook.result.current.items).toEqual([]);
const priv = setup([]);
priv.emit({
lastPlaylistEvent: {
type: "created",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(priv.hook.result.current.items).toEqual([]);
const followed = setup([], { noNewEntries: true });
followed.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
expect(followed.hook.result.current.items).toEqual([]);
});
test("updated: in place, removed when private, re-added when public", () => {
const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "two" } },
});
expect(hook.result.current.items[0].title).toBe("two");
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, isPublic: false } },
});
expect(hook.result.current.items).toEqual([]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "back" } },
});
expect(hook.result.current.items[0].title).toBe("back");
});
test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
const p = makePlaylist({ id: "p" });
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
const { hook, emit } = setup([], opts);
emit({ lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p } });
expect(hook.result.current.items).toEqual([]);
}
});
test("deleted and deletedPlaylistIds both remove", () => {
const { hook, emit } = setup([
makePlaylist({ id: "a" }),
makePlaylist({ id: "b" }),
makePlaylist({ id: "c" }),
]);
emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
emit({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(hook.result.current.items)).toEqual(["c"]);
});
test("dumps_updated sets the count", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit({
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y", "z"] },
});
expect(hook.result.current.items[0].dumpCount).toBe(3);
});
});
describe("usePositionAwareSync", () => {
test("a dump that goes private and back returns to its old place", () => {
const ws = liveWS();
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
// Wire the pair exactly as UserPublicProfile does.
const both = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Dump | null>(null);
usePositionAwareSync(
items,
setItems,
event,
(d) => d.isPrivate,
(d) => !d.isPrivate,
);
useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
return { items, setEvent };
}, { wrapper: ws.Wrapper });
const b = initial[1];
act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
});
test("a position is only used once, and clamps to the list end", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
const hook = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Playlist | null>(null);
usePositionAwareSync(items, setItems, event, (p) => !p.isPublic, (p) => p.isPublic);
return { items, setItems, setEvent };
});
const c = initial[2];
act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
// Nothing saved any more: a second public event is left alone.
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true, title: "x" }));
expect(ids(hook.result.current.items)).toEqual(["a"]);
});
});

View File

@@ -0,0 +1,261 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import type { AuthResponse } from "../../model.ts";
import { useAuth, useRequiredAuth } from "../../hooks/useAuth.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, route } from "../fetch.ts";
import { makeSession, makeToken, storeSession } from "../fixtures.ts";
function wrapperFor(initial: AuthResponse | null) {
return function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(initial);
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
);
};
}
const stored = () => JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
describe("useAuth", () => {
test("exposes the session", () => {
const session = makeSession({ username: "ann" });
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
expect(result.current.user?.username).toBe("ann");
expect(result.current.token).toBe(session.token);
expect(result.current.mediaToken).toBe(session.mediaToken);
});
test("mediaToken falls back to the session token on old sessions", () => {
const session = { ...makeSession(), mediaToken: undefined };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
expect(result.current.mediaToken).toBe(session.token);
});
test("login stores the session; logout clears it", () => {
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
const session = makeSession();
act(() => result.current.login(session));
expect(result.current.user?.id).toBe(session.user.id);
expect(stored().token).toBe(session.token);
act(() => result.current.logout());
expect(result.current.user).toBeNull();
expect(stored()).toBeNull();
});
test("a stale logout doesn't sign out a newer session", () => {
const first = makeSession();
storeSession(first);
const { result } = renderHook(useAuth, { wrapper: wrapperFor(first) });
const staleLogout = result.current.logout;
const second = { ...first, token: makeToken() };
act(() => result.current.login(second));
act(() => staleLogout());
expect(result.current.token).toBe(second.token);
expect(stored().token).toBe(second.token);
});
test("updateSession merges and persists; does nothing signed out", () => {
const session = makeSession();
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
act(() => result.current.updateSession({ mediaToken: "fresh" }));
expect(result.current.mediaToken).toBe("fresh");
expect(stored().mediaToken).toBe("fresh");
const out = renderHook(useAuth, { wrapper: wrapperFor(null) });
act(() => out.result.current.updateSession({ mediaToken: "x" }));
expect(out.result.current.user).toBeNull();
});
});
describe("authFetch", () => {
test("sends the bearer token and a JSON content type", async () => {
const session = makeSession();
route("POST /api/thing", () => ({}));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
await result.current.authFetch("http://localhost:8000/api/thing", {
method: "POST",
body: "{}",
headers: { "X-Extra": "1" },
});
const [req] = calls("POST /api/thing");
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
expect(req.headers.get("Content-Type")).toBe("application/json");
expect(req.headers.get("X-Extra")).toBe("1");
});
test("leaves Content-Type to the browser for FormData", async () => {
route("POST /api/upload", () => ({}));
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
await result.current.authFetch("http://localhost:8000/api/upload", {
method: "POST",
body: new FormData(),
});
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(false);
});
test("no Authorization header when signed out", async () => {
route("GET /api/open", () => ({}));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
await result.current.authFetch("http://localhost:8000/api/open");
expect(calls("GET /api/open")[0].headers.has("Authorization")).toBe(false);
});
test("a 401 signs the session out", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/secret", () => new Response(null, { status: 401 }));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
result.current.authFetch("http://localhost:8000/api/secret")
);
expect(res.status).toBe(401);
expect(result.current.user).toBeNull();
expect(stored()).toBeNull();
});
test("an expired token never leaves the client", async () => {
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
result.current.authFetch("http://localhost:8000/api/x")
);
expect(res.status).toBe(401);
expect(calls("GET /api/x")).toHaveLength(0);
expect(result.current.user).toBeNull();
});
});
describe("authUpload", () => {
class FakeXHR {
static last: FakeXHR;
method = "";
url = "";
headers: Record<string, string> = {};
status = 0;
statusText = "";
responseText = "";
body: unknown;
private listeners: Record<string, Array<(e: unknown) => void>> = {};
private uploadListeners: Record<string, Array<(e: unknown) => void>> = {};
upload = {
addEventListener: (t: string, fn: (e: unknown) => void) =>
(this.uploadListeners[t] ??= []).push(fn),
};
constructor() {
FakeXHR.last = this;
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
addEventListener(t: string, fn: (e: unknown) => void) {
(this.listeners[t] ??= []).push(fn);
}
send(body: unknown) {
this.body = body;
}
progress(loaded: number, total: number, lengthComputable = true) {
this.uploadListeners.progress?.forEach((f) =>
f({ loaded, total, lengthComputable })
);
}
uploaded() {
this.uploadListeners.load?.forEach((f) => f({}));
}
respond(status: number, text: string) {
this.status = status;
this.responseText = text;
this.listeners.load?.forEach((f) => f({}));
}
error() {
this.listeners.error?.forEach((f) => f({}));
}
}
afterEach(() => vi.unstubAllGlobals());
test("reports progress, then null while the server works, then the response", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
const session = makeSession();
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const progress: Array<number | null> = [];
const body = new FormData();
const promise = result.current.authUpload(
"http://localhost:8000/api/dumps",
body,
(f) => progress.push(f),
);
const xhr = FakeXHR.last;
expect(xhr.method).toBe("POST");
expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`);
expect(xhr.body).toBe(body);
xhr.progress(50, 100);
xhr.progress(10, 0, false);
xhr.uploaded();
xhr.respond(201, JSON.stringify({ success: true, data: { id: "d" } }));
const res = await promise;
expect(progress).toEqual([0.5, null, null]);
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ success: true, data: { id: "d" } });
});
test("a network failure rejects like fetch does", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {}, "PUT");
expect(FakeXHR.last.method).toBe("PUT");
FakeXHR.last.error();
await expect(promise).rejects.toBeInstanceOf(TypeError);
});
test("a 401 signs out", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {});
act(() => FakeXHR.last.respond(401, ""));
await promise;
await waitFor(() => expect(result.current.user).toBeNull());
});
test("an expired token short-circuits", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
FakeXHR.last = undefined as unknown as FakeXHR;
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
result.current.authUpload("u", new FormData(), () => {})
);
expect(res.status).toBe(401);
expect(FakeXHR.last).toBeUndefined();
});
});
describe("useRequiredAuth", () => {
test("throws outside a session", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(useRequiredAuth, { wrapper: wrapperFor(null) }))
.toThrow(/Invariant/);
});
test("returns the user inside one", () => {
const { result } = renderHook(useRequiredAuth, {
wrapper: wrapperFor(makeSession({ username: "z" })),
});
expect(result.current.user.username).toBe("z");
});
});

View File

@@ -0,0 +1,338 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { Dump } from "../../pages/Dump.tsx";
import { DumpEdit } from "../../pages/DumpEdit.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
makeComment,
makeDump,
makePlaylist,
makeSession,
makeUser,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" });
const other = makeSession({ id: "other", username: "other" });
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
function dumpRoutes(dump = makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", comment: "Because **reasons**", categoryIds: ["c1"] })) {
route("GET /api/dumps/:id", () => wire(dump));
route("GET /api/users/by-id/:id", () => wire(makeUser({ id: "owner", username: "owner" })));
route("GET /api/dumps/:id/comments", () => []);
route("GET /api/dumps/:id/related", () => []);
route("GET /api/dumps/:id/playlists", () => []);
return dump;
}
function openDump(session: ReturnType<typeof makeSession> | null = null, url = "/dumps/a-find") {
const user = userEvent.setup();
renderApp(<Dump />, {
route: url,
path: "/dumps/:selectedDump",
session,
categories: [music],
extraRoutes: <Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />,
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
}
describe("Dump page", () => {
test("shows the dump, its author, categories and note", async () => {
dumpRoutes();
openDump();
expect(await screen.findByRole("heading", { name: "A find" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute("href", "/users/owner");
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
expect(screen.getByText("reasons").tagName).toBe("STRONG");
await waitFor(() => expect(document.title).toMatch(/^A find · /));
expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find");
});
test("a missing dump is an error page", async () => {
route("GET /api/dumps/:id", () => fail("Dump not found", 404));
route("GET /api/dumps/:id/comments", () => []);
route("GET /api/dumps/:id/related", () => []);
route("GET /api/dumps/:id/playlists", () => []);
openDump();
expect(await screen.findByRole("alert")).toHaveTextContent("Dump not found");
expect(screen.getByRole("button", { name: "← Back to all dumps" })).toBeInTheDocument();
});
test("the owner can rename it in place, following the new slug", async () => {
dumpRoutes();
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "a-better-find", title: "A better find", userId: "owner" })));
const { user } = openDump(owner);
await user.click(await screen.findByRole("heading", { name: "A find" }));
const input = screen.getByDisplayValue("A find");
await user.clear(input);
await user.type(input, "A better find{Enter}");
expect(await screen.findByRole("heading", { name: "A better find" })).toBeInTheDocument();
await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find"));
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ title: "A better find" });
});
test("a refused rename says why; Escape backs out", async () => {
dumpRoutes();
route("PUT /api/dumps/:id", () => fail("Title taken", 400));
const { user } = openDump(owner);
await user.click(await screen.findByRole("heading", { name: "A find" }));
await user.type(screen.getByDisplayValue("A find"), "!{Enter}");
expect(await screen.findByText("Title taken")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
});
test("others can't rename or edit it", async () => {
dumpRoutes();
const { user } = openDump(other);
await user.click(await screen.findByRole("heading", { name: "A find" }));
expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Edit" })).not.toBeInTheDocument();
});
test("the owner gets an Edit link", async () => {
dumpRoutes();
const { user } = openDump(owner);
await user.click(await screen.findByRole("link", { name: "Edit" }));
expect(currentPath()).toBe("/dumps/a-find/edit");
});
test("the title can be edited from the keyboard", async () => {
dumpRoutes();
const { user } = openDump(owner);
await screen.findByRole("heading", { name: "A find" });
screen.getByTitle("Edit title").focus();
await user.keyboard("{Enter}");
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
});
test("votes go through the socket", async () => {
dumpRoutes(makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }));
const { user, socket } = openDump(other);
await user.click(await screen.findByRole("button", { name: "Upvote" }));
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
expect(screen.getByRole("button", { name: "Remove vote" })).toHaveTextContent("3");
});
test("comments load, and live ones arrive", async () => {
dumpRoutes();
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]));
const { socket } = openDump(other);
await screen.findByText("first");
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })) });
expect(screen.getByText("second")).toBeInTheDocument();
socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" });
expect(screen.getByText("[deleted]")).toBeInTheDocument();
// Another dump's comment isn't shown here.
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" })) });
expect(screen.queryByText("elsewhere")).not.toBeInTheDocument();
});
test("related dumps and the playlists it's in", async () => {
dumpRoutes();
route("GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })]));
route("GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })]));
openDump();
expect(await screen.findByRole("heading", { name: "Related" })).toBeInTheDocument();
expect(screen.getByText("Links here")).toBeInTheDocument();
expect(await screen.findByText("Best of")).toBeInTheDocument();
});
test("a live update of this dump is applied", async () => {
const d = dumpRoutes();
const { socket } = openDump();
await screen.findByRole("heading", { name: "A find" });
socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }) });
expect(screen.getByRole("heading", { name: "Retitled" })).toBeInTheDocument();
});
test("adding to a playlist", async () => {
dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => [
{ playlist: wire(makePlaylist({ id: "p", title: "Mine" })), hasDump: true },
]);
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
const row = await screen.findByRole("checkbox", { name: /Mine/ });
expect(row).toHaveAttribute("aria-checked", "true");
await user.click(row);
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "false"));
});
test("a new playlist can be made from there, with the dump in it", async () => {
dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => []);
route("POST /api/playlists", (req) => wire(makePlaylist({ id: "np", title: (req.body as { title: string }).title })));
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
await user.click(await screen.findByRole("button", { name: "+ New playlist" }));
await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}");
const row = await screen.findByRole("checkbox", { name: /Fresh/ });
expect(row).toHaveAttribute("aria-checked", "true");
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/np/dumps/d1");
});
test("scrolls to a linked comment", async () => {
dumpRoutes();
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]));
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
openDump(null, "/dumps/a-find#comment-c9");
await screen.findByText("linked");
await waitFor(() => expect(document.getElementById("comment-c9")).toHaveClass("comment-node--highlight"));
expect(scroll).toHaveBeenCalled();
});
});
describe("DumpEdit page", () => {
function openEdit(dump: ReturnType<typeof makeDump>, session = owner) {
route("GET /api/dumps/:id", () => wire(dump));
const user = userEvent.setup();
renderApp(
<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>,
{
route: `/dumps/${dump.slug ?? dump.id}/edit`,
path: "/dumps/:selectedDump/edit",
session,
categories: [music],
extraRoutes: (
<>
<Route path="/dumps/:selectedDump" element={<p>dump page</p>} />
<Route path="/" element={<p>home</p>} />
</>
),
},
);
FakeWebSocket.last.welcome();
return user;
}
const urlDump = () =>
makeDump({
id: "d1",
slug: "a-find",
title: "A find",
userId: "owner",
url: "https://a.test/",
comment: "old why",
categoryIds: [],
richContent: { type: "link", url: "https://a.test/", title: "Page's own title" },
});
test("saves title, note, categories and visibility", async () => {
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "new-title" })));
const user = openEdit(urlDump());
const title = await screen.findByLabelText("Title");
expect(document.title).toMatch(/^Editing A find/);
await user.clear(title);
await user.type(title, "New title");
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
expect(currentPath()).toBe("/dumps/new-title");
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
url: "https://a.test/",
title: "New title",
comment: "old why",
isPrivate: true,
categoryIds: ["c1"],
});
});
test("clearing the note removes it", async () => {
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1" })));
const user = openEdit(urlDump());
await user.clear(await screen.findByLabelText("Why?"));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null);
});
test("the title resets to the page's own", async () => {
const user = openEdit(urlDump());
const title = await screen.findByLabelText("Title");
await user.click(screen.getByRole("button", { name: "Reset to default" }));
expect(title).toHaveValue("Page's own title");
expect(screen.getByRole("button", { name: "Reset to default" })).toBeDisabled();
});
test("a refused save stays on the form", async () => {
route("PUT /api/dumps/:id", () => fail("Invalid URL", 400));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Save" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Could not save");
expect(alert).toHaveTextContent("Invalid URL");
});
test("refreshing the metadata", async () => {
route("POST /api/dumps/:id/refresh-metadata", () =>
wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb" } }));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Refresh metadata" }));
expect(await screen.findByText("New blurb")).toBeInTheDocument();
});
test("replacing a file keeps a visibility change made alongside", async () => {
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
route("PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3" }));
route("PUT /api/dumps/:id", (req) => wire({ ...fileDump, ...(req.body as object) }));
const user = openEdit(fileDump);
await screen.findByText("song.mp3");
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
const [put] = calls("PUT /api/dumps/:id/file");
expect((put.body as FormData).get("file")).toBeInstanceOf(File);
expect(calls("PUT /api/dumps/:id")[0]?.body).toMatchObject({ isPrivate: true });
});
test("deleting asks, then goes home", async () => {
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
await screen.findByText("home");
expect(JSON.parse(document.querySelector('[data-testid="location"]')!.getAttribute("data-state")!))
.toEqual({ deletedDumpId: "d1" });
});
test("a delete that can't reach the server says so", async () => {
route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch"));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
});
test("a thumbnail that won't upload says so", async () => {
route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400));
const user = openEdit(urlDump());
await screen.findByLabelText("Title");
const picker = document.querySelector<HTMLInputElement>('.dump-edit-thumbnail-row input[type="file"]')!;
await user.upload(picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" }));
expect(await screen.findByText("Not an image")).toBeInTheDocument();
});
test("guests are sent to log in", () => {
renderApp(<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>, {
route: "/dumps/x/edit",
path: "/dumps/:selectedDump/edit",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
expect(screen.getByText("login page")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,198 @@
import { describe, expect, test } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useParams } from "react-router";
import { Index } from "../../pages/Index.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makeSession, page, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
const HOUR = 3_600_000;
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
function Scoped() {
const { scope } = useParams();
const categorySlug = scope === "~" ? undefined : scope;
return <Index key={scope} categorySlug={categorySlug} />;
}
function setup(
url: string,
{ session = null as ReturnType<typeof makeSession> | null } = {},
) {
const user = userEvent.setup();
renderApp(<Scoped />, {
route: url,
path: "/:scope/:feedTab",
session,
categories: [music],
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title, .journal-card-title"))
.map((a) => a.textContent);
describe("Index feeds", () => {
const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) });
const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) });
test("New sorts by date, Hot by score", async () => {
route("GET /api/dumps/", () => page([older, newer]));
const { user } = setup("/~/new");
await screen.findByText("Brand new");
expect(titles()).toEqual(["Brand new", "Old but loved"]);
await user.click(screen.getAllByRole("button", { name: "Hot" })[0]);
expect(currentPath()).toBe("/~/hot");
expect(titles()).toEqual(["Old but loved", "Brand new"]);
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
// One fetch: switching tabs re-sorts what's loaded.
expect(calls("GET /api/dumps/")).toHaveLength(1);
});
test("Journal lays the feed out as a mosaic", async () => {
route("GET /api/dumps/", () => page([older, newer]));
setup("/~/journal");
await screen.findByText("Brand new");
expect(document.querySelector(".journal-grid")).not.toBeNull();
});
test("an empty feed and a failed one", async () => {
route("GET /api/dumps/", () => page([]));
const first = setup("/~/new");
await screen.findByText("No dumps yet. Be the first!");
first.socket.drop();
});
test("a failed load shows an error and disables posting", async () => {
route("GET /api/dumps/", () => fail("boom", 500));
setup("/~/new", { session: makeSession() });
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
});
test("sends the token so your own private dumps come back", async () => {
const session = makeSession();
route("GET /api/dumps/", () => page([]));
setup("/~/new", { session });
await screen.findByText("No dumps yet. Be the first!");
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("scrolling to the end loads the next page", async () => {
route("GET /api/dumps/", (req) =>
req.url.searchParams.get("page") === "1"
? page([newer], true)
: page([older], false));
setup("/~/new");
await screen.findByText("Brand new");
const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never));
await screen.findByText("Old but loved");
expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2");
});
test("live: a new dump goes on top; a deleted one goes away", async () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "live", title: "Just now", createdAt: new Date() })) });
expect(titles()[0]).toBe("Just now");
socket.receive({ type: "dump_deleted", dumpId: "old" });
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
});
test("live votes update the count", async () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" });
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31");
});
test("guests can't vote", async () => {
route("GET /api/dumps/", () => page([older]));
setup("/~/new");
await screen.findByText("Old but loved");
expect(screen.getByRole("button", { name: "Upvote" })).toBeDisabled();
});
test("a reconnect backfills what was missed", async () => {
route("GET /api/dumps/", () => page([older]), { once: true });
route("GET /api/dumps/", () => page([newer, older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("Brand new");
});
test("a category scopes the feed, and live arrivals to it", async () => {
route("GET /api/categories/:slug/dumps", () => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]));
const { socket } = setup("/music/new");
await screen.findByText("A song");
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe("music");
await waitFor(() => expect(document.title).toMatch(/^Music · /));
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) });
expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument();
expect(screen.getByText("Another song")).toBeInTheDocument();
});
test("an unknown category is a 404", async () => {
route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
setup("/nonsense/new");
expect(await screen.findByRole("heading", { name: "404" })).toBeInTheDocument();
});
test("a guest on the followed tab gets the default feed, not a blank page", async () => {
route("GET /api/dumps/", () => page([older]));
setup("/~/followed");
await screen.findByText("Old but loved");
});
test("the Android share sheet opens the new-dump form with the link", async () => {
route("GET /api/dumps/", () => page([]));
route("GET /api/preview", () => ({ reached: false, richContent: null }));
route("GET /api/dumps/by-url", () => []);
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", { session: makeSession() });
const dialog = await screen.findByRole("dialog", { name: "New dump" });
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue("https://shared.test/");
});
});
describe("Followed feed", () => {
test("from people and from playlists", async () => {
const session = makeSession();
route("GET /api/follows/feed/users", () => page([makeDump({ title: "By a friend" })]));
route("GET /api/follows/feed/playlists", () => page([]));
const { user } = setup("/~/followed", { session });
await screen.findByText("By a friend");
await user.click(screen.getByRole("button", { name: "From playlists" }));
expect(screen.getByText("Follow some public playlists to see their dumps here.")).toBeInTheDocument();
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("a category narrows it", async () => {
route("GET /api/follows/feed/users", () => page([]));
route("GET /api/follows/feed/playlists", () => page([]));
setup("/music/followed", { session: makeSession() });
await screen.findByText("Follow some users to see their dumps here.");
expect(calls("GET /api/follows/feed/users")[0].url.searchParams.get("category")).toBe("music");
});
test("a server error reads as one, not as a lost connection", async () => {
route("GET /api/follows/feed/users", () => fail("Internal error", 500));
route("GET /api/follows/feed/playlists", () => page([]));
setup("/~/followed", { session: makeSession() });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Internal error");
expect(alert).not.toHaveTextContent(/Could not connect/);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const me = makeSession({ id: "me" });
const DAY = 86_400_000;
function open(items: Notification[], { hasMore = false } = {}) {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([]));
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: items.filter((n) => !n.read).length });
return { user, socket };
}
const n = {
follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }),
playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }),
posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }),
added: makeNotification({ type: "playlist_dump_added", data: { dumpId: "d2", dumpTitle: "Tune", playlistId: "p", playlistTitle: "Mix" } }),
upvoted: makeNotification({ type: "dump_upvoted", data: { voterId: "v", voterUsername: "bob", dumpId: "d3", dumpTitle: "Pic" } }),
commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }),
liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }),
mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }),
mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }),
mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", contextTitle: "Faves" } }),
};
describe("Notifications", () => {
test("every kind reads right and links to its subject", async () => {
open(Object.values(n));
const link = async (text: RegExp) => (await screen.findByText(text)).closest("a")!.getAttribute("href");
expect(await link(/started following you/)).toBe("/users/fan");
expect(await link(/followed your playlist/)).toBe("/playlists/p1");
expect(await link(/^posted$/)).toBe("/dumps/d1");
expect(await link(/was added to/)).toBe("/dumps/d2");
expect(await link(/^upvoted$/)).toBe("/dumps/d3");
expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1");
expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2");
const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href"));
expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]);
expect(screen.getByText("a comment")).toBeInTheDocument();
expect(screen.getByText("the chat")).toBeInTheDocument();
expect(screen.getByText("Faves")).toBeInTheDocument();
});
test("grouped by day", async () => {
open([
makeNotification({ createdAt: new Date() }),
makeNotification({ createdAt: new Date(Date.now() - DAY - 60_000) }),
makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }),
]);
expect(await screen.findByRole("heading", { name: "Today" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument();
});
test("opening them marks them read and clears the bell, but still shows which were new", async () => {
open([n.follow, { ...n.upvoted, read: true }]);
await screen.findByText(/started following you/);
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
);
expect(document.querySelectorAll(".notif-dot")).toHaveLength(1);
expect(screen.getByText(/started following you/).closest("li"))
.toHaveClass("notification-item--unread");
expect(document.querySelector(".notifications-unread-pill")).toHaveTextContent("1 new");
});
test("a list that loaded stays shown if marking it read fails", async () => {
route("POST /api/notifications/read-all", () => new TypeError("offline"));
open([n.follow]);
expect(await screen.findByText(/started following you/)).toBeInTheDocument();
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText(/started following you/)).toBeInTheDocument();
});
test("a failed load shows the server's reason", async () => {
route("GET /api/notifications", () => fail("Database is locked", 500));
renderApp(<Notifications />, { session: me });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load");
expect(alert).toHaveTextContent("Database is locked");
expect(calls("POST /api/notifications/read-all")).toHaveLength(0);
});
test("empty", async () => {
open([]);
expect(await screen.findByText("Nothing here yet.")).toBeInTheDocument();
});
test("a new one arrives at the top while the page is open", async () => {
const { socket } = open([n.follow]);
await screen.findByText(/started following you/);
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
const items = document.querySelectorAll(".notification-item");
expect(items[0]).toHaveTextContent("bob upvoted Pic");
expect(items[0]).toHaveClass("notification-item--unread");
// Seen as it arrives: marked read, so the bell doesn't count it.
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(2));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
);
});
test("load more", async () => {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1"
? page([n.follow], true)
: page([n.upvoted], false));
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load more" }));
await screen.findByText(/^upvoted$/);
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,220 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
import type { Dump, Playlist } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" });
const visitor = makeSession({ id: "visitor" });
const dumps = () => [
makeDump({ id: "a", title: "Alpha" }),
makeDump({ id: "b", title: "Bravo" }),
makeDump({ id: "c", title: "Charlie" }),
];
function open(
{ session = owner as ReturnType<typeof makeSession> | null, playlist = {} as Partial<Playlist>, items = dumps() as Dump[] } = {},
) {
const pl = makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
ownerUsername: "owner",
description: "Good **tunes**",
...playlist,
});
route("GET /api/playlists/:id", () => wire({ ...pl, dumps: items }));
const user = userEvent.setup();
renderApp(<PlaylistDetail />, {
route: "/playlists/mix",
path: "/playlists/:playlistId",
session,
extraRoutes: <Route path="/" element={<p>home</p>} />,
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket, pl };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
describe("PlaylistDetail", () => {
test("shows the playlist and its dumps in order", async () => {
open({ session: null });
expect(await screen.findByRole("heading", { name: "Mix" })).toBeInTheDocument();
expect(screen.getByText("tunes").tagName).toBe("STRONG");
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute("href", "/users/owner");
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
await waitFor(() => expect(document.title).toMatch(/^Mix · /));
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
});
test("not found", async () => {
route("GET /api/playlists/:id", () => fail("nope", 404));
renderApp(<PlaylistDetail />, { route: "/playlists/x", path: "/playlists/:playlistId" });
expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found");
});
test("a visitor can follow a public playlist, not edit it", async () => {
open({ session: visitor });
await screen.findByRole("heading", { name: "Mix" });
expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument();
});
test("the cover is versioned on the last edit", async () => {
open({ playlist: { imageMime: "image/png", updatedAt: new Date(5000) } });
await screen.findByRole("heading", { name: "Mix" });
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/image?v=5000",
);
});
test("editing the description shows at once, without waiting for the socket", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getByPlaceholderText("Description (optional)");
await user.clear(box);
await user.type(box, "Better tunes");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ description: "Better tunes", isPublic: true });
expect(currentPath()).toBe("/playlists/mix");
});
test("a new title moves to the new slug", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const title = screen.getByPlaceholderText("Playlist title");
await user.clear(title);
await user.type(title, "Remix");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(currentPath()).toBe("/playlists/remix"));
});
test("a new cover shows once saved; a refused one says why", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" })));
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true });
route("POST /api/playlists/:id/image", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const picker = document.querySelector<HTMLInputElement>('.img-picker input[type="file"]')!;
await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/image?v=9000",
)
);
});
test("Escape leaves the editor", async () => {
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.type(screen.getByPlaceholderText("Playlist title"), "{Escape}");
expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument();
});
test("removing a dump fades it out, with an undo", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
route("PUT /api/playlists/:pid/order", () => ({}));
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b");
await user.click(within(bravo).getByRole("button", { name: "Cancel removal" }));
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] });
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test("a refused removal puts the dump back and says so", async () => {
route("DELETE /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed");
expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument();
});
test("dragging reorders, and a refused reorder reloads the real order", async () => {
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
open();
await screen.findByText("Charlie");
const items = () => Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
for (const el of items()) {
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
}
const [a, , c] = items();
fireEvent.dragStart(a);
fireEvent.dragOver(c, { clientY: 90 });
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
fireEvent.dragEnd(items()[2]);
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] });
await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2));
await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]));
});
test("deleting the playlist asks, then goes home", async () => {
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByText("home")).toBeInTheDocument();
});
test("a refused delete stays and says why", async () => {
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(currentPath()).toBe("/playlists/mix");
});
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
route("GET /api/dumps/:id", () => wire(makeDump({ id: "d", title: "Delta" })));
const { socket } = open({ session: visitor });
await screen.findByText("Alpha");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p1", dumpIds: ["d", "a", "b", "c"] });
expect(await screen.findByText("Delta")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) });
expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument();
socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "owner" });
expect(await screen.findByText("home")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from "vitest";
import { act, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Search } from "../../pages/Search.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, makeUser, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeIntersectionObserver } from "../setup.ts";
function results(
{ dumps = [] as ReturnType<typeof makeDump>[], hasMore = false, users = [] as ReturnType<typeof makeUser>[], playlists = [] as ReturnType<typeof makePlaylist>[] } = {},
) {
return {
dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore },
users: wire(users),
playlists: wire(playlists),
};
}
function open(url: string, session: ReturnType<typeof makeSession> | null = null) {
const user = userEvent.setup();
renderApp(<Search />, { route: url, path: "/search/*", session });
return user;
}
describe("Search", () => {
test("no query: a prompt, no request", () => {
open("/search");
expect(screen.getByText("Enter a query to search.")).toBeInTheDocument();
expect(calls("GET /api/search")).toHaveLength(0);
});
test("results by tab, with counts", async () => {
route("GET /api/search", () =>
results({
dumps: [makeDump({ title: "Cat video" })],
users: [makeUser({ username: "catlover", description: "meow" })],
playlists: [makePlaylist({ title: "Cats" })],
}));
const user = open("/search?q=cat", makeSession());
await screen.findByText("Cat video");
const [req] = calls("GET /api/search");
expect(req.url.searchParams.get("q")).toBe("cat");
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
await user.click(screen.getByRole("button", { name: "Users (1)" }));
expect(currentPath()).toBe("/search/~/users?q=cat");
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover");
await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
expect(screen.getByText("Cats")).toBeInTheDocument();
});
test("nothing found", async () => {
route("GET /api/search", () => results());
const user = open("/search?q=zzz");
expect(await screen.findByText('No dumps match "zzz".')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Users (0)" }));
expect(screen.getByText('No users match "zzz".')).toBeInTheDocument();
});
test("a failed search says why", async () => {
route("GET /api/search", () => fail("Query too short", 400));
open("/search?q=a");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Search failed");
expect(alert).toHaveTextContent("Query too short");
});
test("more dumps on scroll; a failed page keeps what's shown", async () => {
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
route("GET /api/search", () => new TypeError("offline"), { once: true });
route("GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] }));
open("/search?q=x");
await screen.findByText("First");
const scroll = () => {
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
};
scroll();
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
scroll();
await screen.findByText("Second");
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe("2");
});
test("a new query replaces the results", async () => {
route("GET /api/search", (req) =>
results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] }));
const user = open("/search?q=one");
await screen.findByText("About one");
const [box] = screen.getAllByRole("searchbox");
await user.clear(box);
await user.type(box, "two{Enter}");
await screen.findByText("About two");
expect(screen.queryByText("About one")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserPlaylists } from "../../pages/UserPlaylists.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makePlaylist, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const ann = makeUser({ id: "ann", username: "ann" });
const annSession = makeSession({ id: "ann", username: "ann" });
function open(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/playlists", () =>
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true));
route("GET /api/users/:u/followed-playlists", () =>
page([makePlaylist({ id: "f1", title: "Someone's mix", userId: "bob", ownerUsername: "bob" })]));
const user = userEvent.setup();
renderApp(<UserPlaylists />, { route: "/users/ann/playlists", path: "/users/:username/playlists", session });
return { user, socket: FakeWebSocket.last };
}
describe("UserPlaylists", () => {
test("created and followed playlists", async () => {
open();
expect(await screen.findByText("Her mix")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Created (1+)" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Followed (1)" })).toBeInTheDocument();
expect(screen.getByText("Someone's mix")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Delete playlist" })).not.toBeInTheDocument();
});
test("the owner creates one and it goes on top", async () => {
route("POST /api/playlists", (req) =>
wire(makePlaylist({ id: "new", title: (req.body as { title: string }).title, userId: "ann" })));
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: /New playlist/ }));
const dialog = screen.getByRole("dialog", { name: "New playlist" });
await user.type(within(dialog).getByPlaceholderText("Title"), "Road trip");
await user.click(within(dialog).getByRole("button", { name: "Private" }));
await user.click(within(dialog).getByRole("button", { name: "Create" }));
expect(await screen.findByText("Road trip")).toBeInTheDocument();
expect(calls("POST /api/playlists")[0].body).toEqual({ title: "Road trip", isPublic: false });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
test("the owner deletes one, after confirming", async () => {
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
await screen.findByText("No playlists yet.");
});
test("a refused delete keeps the playlist and says why", async () => {
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(screen.getByText("Her mix")).toBeInTheDocument();
});
test("live: her new public playlist appears, one gone private leaves", async () => {
const { socket } = open();
await screen.findByText("Her mix");
socket.welcome();
socket.receive({ type: "playlist_created", playlist: wire(makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" })) });
expect(screen.getByText("Brand new mix")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Her mix", userId: "ann", isPublic: false })) });
expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,316 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx";
import type { PublicUser } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
makeDump,
makePlaylist,
makeSession,
makeUser,
page,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
const ann = makeUser({ id: "ann", username: "ann", description: "I **dump** things", invitedByUsername: "root" });
const annSession = makeSession({ id: "ann", username: "ann", email: "ann@x.test" });
function open(
{
session = null as ReturnType<typeof makeSession> | null,
user = ann as PublicUser,
url = "/users/ann",
dumps = [makeDump({ id: "d1", title: "Her dump", userId: "ann" })],
votes = [makeDump({ id: "v1", title: "She liked this" })],
categories = [] as ReturnType<typeof makeCategory>[],
} = {},
) {
route("GET /api/users/:u", () => wire(user));
route("GET /api/users/:u/dumps", () => page(dumps));
route("GET /api/users/:u/votes", () => page(votes));
route("GET /api/users/:u/playlists", () => page([makePlaylist({ title: "Her mix", userId: "ann" })]));
const u = userEvent.setup();
renderApp(<UserPublicProfile />, {
route: url,
path: "/users/:username/*",
session,
categories,
});
return { user: u, socket: FakeWebSocket.last };
}
describe("UserPublicProfile", () => {
test("header, description, and the dumps tab", async () => {
open();
expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root");
expect(screen.getByText("dump").tagName).toBe("STRONG");
expect(screen.getByText("Her dump")).toBeInTheDocument();
expect(screen.getByText("She liked this")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps (1)" })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^ann · /));
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
});
test("an unknown user", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
route("GET /api/users/:u/votes", () => page([]));
route("GET /api/users/:u/playlists", () => page([]));
renderApp(<UserPublicProfile />, { route: "/users/nobody", path: "/users/:username/*" });
expect(await screen.findByRole("alert")).toHaveTextContent("User not found");
});
test("a role chip for moderators, and none for users", async () => {
open({ user: { ...ann, role: "moderator" } });
expect(await screen.findByText("Moderator")).toBeInTheDocument();
});
test("tabs are in the URL", async () => {
const { user } = open();
await screen.findByRole("heading", { name: "ann" });
await user.click(screen.getByRole("button", { name: "Playlists" }));
expect(currentPath()).toBe("/users/ann/~/playlists");
expect(screen.getByText("Her mix")).toBeInTheDocument();
});
test("followed: people and playlists, with more people on demand", async () => {
route("GET /api/users/:u/followed-users", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "bob" })], true)
: page([makeUser({ username: "cy" })]));
route("GET /api/users/:u/followed-playlists", () => page([makePlaylist({ title: "Their mix" })]));
const { user } = open({ url: "/users/ann/~/followed" });
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute("href", "/users/bob");
expect(screen.getByText("Their mix")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Load more" }));
expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
});
test("invitees as a tree", async () => {
route("GET /api/users/:u/invitees", () => wire([
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
]));
open({ url: "/users/ann/~/invitees" });
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest("li")!;
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
});
test("visitors can follow; signed in, a settings tab is only on your own profile", async () => {
open({ session: makeSession({ id: "bob" }) });
expect(await screen.findByRole("button", { name: "Follow ann" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
});
describe("your own profile", () => {
test("edit the description; clearing it really clears it", async () => {
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
const { user } = open({ session: annSession });
const view = (await screen.findByText("dump")).closest("[title='Edit description']") as HTMLElement;
view.focus();
await user.keyboard("{Enter}");
const box = screen.getByPlaceholderText("Who am I?");
await user.clear(box);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Who am I?")).toBeInTheDocument();
expect(calls("PATCH /api/users/me")[0].body).toEqual({ description: "" });
});
test("edit the email", async () => {
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
const input = screen.getByDisplayValue("ann@x.test");
await user.clear(input);
await user.type(input, "new@x.test{Enter}");
expect(await screen.findByRole("button", { name: /new@x\.test/ })).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email).toBe("new@x.test");
});
test("a refused email says why", async () => {
route("PATCH /api/users/me", () => fail("Email already in use", 409));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Email already in use");
});
test("upload an avatar", async () => {
route("POST /api/avatars/me", () => wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }));
const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
await waitFor(() =>
expect(document.querySelector(".profile-avatar-wrapper img")).toHaveAttribute(
"src",
"http://localhost:8000/api/avatars/ann?v=7000",
)
);
});
test("a rejected avatar says why", async () => {
route("POST /api/avatars/me", () => fail("Not an image", 400));
const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
});
test("invite someone: a link to copy", async () => {
route("POST /api/invites", () => ({ token: "tok123" }));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: "+ Invite someone" }));
const link = await screen.findByRole("textbox", { name: "Invite link" });
expect(link).toHaveValue(`${location.origin}/register?token=tok123`);
await user.click(screen.getByRole("button", { name: "Copy" }));
// user-event stands in for the clipboard.
expect(await navigator.clipboard.readText()).toBe(`${location.origin}/register?token=tok123`);
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
});
test("your upvotes show before the live connection welcomes you", async () => {
open({ session: annSession });
// No welcome is sent.
expect(await screen.findByText("She liked this")).toBeInTheDocument();
});
test("unvoting fades it out of your upvoted column", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { socket } = open({ session: annSession });
await screen.findByText("She liked this");
socket.welcome({ myVotes: ["v1"] });
socket.receive({ type: "vote_ack", dumpId: "v1", action: "remove", voteCount: 0 });
expect(screen.getByText("She liked this").closest(".dump-card")).toHaveClass("dump-card--fading");
} finally {
vi.useRealTimers();
}
});
test("settings: theme, colour scheme, default tab and sounds", async () => {
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Brutalist" }));
expect(document.documentElement.getAttribute("data-style")).toBe("brutalist");
await user.click(screen.getByRole("button", { name: "Dark" }));
expect(document.documentElement.getAttribute("data-color-scheme")).toBe("dark");
await user.click(screen.getByRole("button", { name: "New" }));
expect(localStorage.getItem("default-feed-tab")).toBe("new");
await user.click(screen.getByRole("button", { name: "Off" }));
expect(localStorage.getItem("sounds")).toBe("off");
document.documentElement.removeAttribute("data-style");
document.documentElement.removeAttribute("data-color-scheme");
});
test("change password keeps you signed in with the fresh tokens", async () => {
route("POST /api/users/me/change-password", () => ({ token: "fresh-token", mediaToken: "fresh-media" }));
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Change password…" }));
const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "oldpassword");
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
await user.type(within(dialog).getByLabelText("Confirm new password"), "different");
expect(await within(dialog).findByText("Passwords do not match")).toBeInTheDocument();
await user.clear(within(dialog).getByLabelText("Confirm new password"));
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword");
await user.click(within(dialog).getByRole("button", { name: "Change password" }));
expect(await within(dialog).findByText("Password changed successfully.")).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe("fresh-token");
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
currentPassword: "oldpassword",
newPassword: "newpassword",
});
});
test("a wrong current password is reported", async () => {
route("POST /api/users/me/change-password", () => fail("Current password is incorrect", 400));
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Change password…" }));
const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "wrong");
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword{Enter}");
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Current password is incorrect");
});
test("log out", async () => {
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: "Log out" }));
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});
describe("manage", () => {
test("an admin can change someone's role", async () => {
route("PATCH /api/users/:id/role", (req) => wire({ ...ann, role: (req.body as { role: string }).role }));
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
await user.click(await screen.findByRole("button", { name: "Moderator" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass("active"));
expect(calls("PATCH /api/users/:id/role")[0].path).toBe("/api/users/ann/role");
expect(document.querySelector(".profile-role-chip")).toHaveTextContent("Moderator");
});
test("a refused role change says why", async () => {
route("PATCH /api/users/:id/role", () => fail("Cannot demote the last admin", 400));
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
await user.click(await screen.findByRole("button", { name: "Admin" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot demote the last admin");
});
test("non-admins get no manage tab", async () => {
open({ session: makeSession({ id: "mod", role: "moderator" }), url: "/users/ann/~/manage" });
await screen.findByRole("heading", { name: "ann" });
expect(screen.queryByRole("button", { name: "Manage" })).not.toBeInTheDocument();
expect(screen.getByText("Her dump")).toBeInTheDocument();
});
test("on your own profile, an admin manages categories", async () => {
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
let list = [music];
route("GET /api/categories", () => wire(list));
route("POST /api/categories", (req) => {
const c = makeCategory({ id: "c2", ...(req.body as object) });
list = [...list, c];
return wire(c);
});
route("DELETE /api/categories/:id", () => {
list = list.filter((c) => c.id !== "c1");
return null;
});
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
await user.click(await screen.findByRole("button", { name: "Add category" }));
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "cinema");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("button", { name: "Cinema" })).toBeInTheDocument();
expect(calls("POST /api/categories")[0].body).toEqual({ name: "Cinema", slug: "cinema" });
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete category" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Music" })).not.toBeInTheDocument());
});
test("a refused category is reported, and Escape closes the editor", async () => {
route("GET /api/categories", () => []);
route("POST /api/categories", () => fail("Slug already taken", 409));
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
await user.click(await screen.findByRole("button", { name: "Add category" }));
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Slug already taken");
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
expect(screen.getByRole("button", { name: "Add category" })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserDumps } from "../../pages/UserDumps.tsx";
import { UserUpvoted } from "../../pages/UserUpvoted.tsx";
import { fail, route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const ann = makeUser({ id: "ann", username: "ann" });
const annSession = makeSession({ id: "ann", username: "ann" });
function openDumps(session: ReturnType<typeof makeSession> | null = null) {
const user = userEvent.setup();
renderApp(<UserDumps />, { route: "/users/ann/dumps", path: "/users/:username/dumps", session });
return { user, socket: FakeWebSocket.last };
}
describe("UserDumps", () => {
test("lists a user's dumps under a header back to the profile", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ title: "Hers", userId: "ann" })]));
openDumps();
expect(await screen.findByText("Hers")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.queryByRole("button", { name: /New dump/ })).not.toBeInTheDocument();
});
test("an unknown user", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
openDumps();
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← Back to profile" })).toBeInTheDocument();
});
test("on your own page: a new-dump button, and your dumps going private stay", async () => {
route("GET /api/users/:u", () => wire(ann));
const mine = makeDump({ id: "m", title: "Mine", userId: "ann" });
route("GET /api/users/:u/dumps", () => page([mine]));
const { user, socket } = openDumps(annSession);
await screen.findByText("Mine");
socket.welcome();
socket.receive({ type: "dump_updated", dump: wire({ ...mine, isPrivate: true }) });
expect(screen.getByText("Mine")).toBeInTheDocument();
route("GET /api/preview", () => ({ reached: false, richContent: null }));
await user.click(screen.getByRole("button", { name: /New dump/ }));
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
});
test("a visitor loses a dump that goes private, and gets it back where it was", async () => {
route("GET /api/users/:u", () => wire(ann));
const list = ["x", "y", "z"].map((id) => makeDump({ id, title: `T-${id}`, userId: "ann" }));
route("GET /api/users/:u/dumps", () => page(list));
const { socket } = openDumps();
await screen.findByText("T-z");
socket.welcome();
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: true }) });
expect(screen.queryByText("T-y")).not.toBeInTheDocument();
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: false }) });
const titles = Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
});
});
describe("UserUpvoted", () => {
const voted = () => [makeDump({ id: "v1", title: "Liked one" }), makeDump({ id: "v2", title: "Liked two" })];
function openVotes(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/votes", () => page(voted()));
const user = userEvent.setup();
renderApp(<UserUpvoted />, { route: "/users/ann/upvoted", path: "/users/:username/upvoted", session });
return { user, socket: FakeWebSocket.last };
}
test("lists what they upvoted", async () => {
openVotes();
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Upvoted" })).toBeInTheDocument();
});
test("your own list shows even before the live connection says what you voted", async () => {
openVotes(annSession);
// The socket is never opened: no welcome, no myVotes.
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByText("Liked two")).toBeInTheDocument();
});
test("unvoting on your own list fades the dump out", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { socket } = openVotes(annSession);
await screen.findByText("Liked one");
socket.welcome({ myVotes: ["v1", "v2"] });
socket.receive({ type: "votes_update", dumpId: "v1", voteCount: 0, voterId: "ann", action: "remove" });
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass("dump-card--fading");
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test("their new upvote appears at the top", async () => {
route("GET /api/dumps/:id", () => wire(makeDump({ id: "v3", title: "Fresh like" })));
const { socket } = openVotes();
await screen.findByText("Liked one");
socket.welcome();
socket.receive({ type: "votes_update", dumpId: "v3", voteCount: 1, voterId: "ann", action: "cast" });
await waitFor(() =>
expect(document.querySelector(".dump-card-title")).toHaveTextContent("Fresh like")
);
});
});

View File

@@ -0,0 +1,237 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { UserLogin } from "../../pages/UserLogin.tsx";
import { UserRegister } from "../../pages/UserRegister.tsx";
import { ResetPassword } from "../../pages/ResetPassword.tsx";
import { NotFound } from "../../pages/NotFound.tsx";
import { RestrictedGuest } from "../../pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
const home = <Route path="/" element={<p>home page</p>} />;
describe("UserLogin", () => {
function setup() {
const user = userEvent.setup();
renderWithContexts(<UserLogin />, { route: "/login", path: "/login", extraRoutes: home });
return user;
}
test("logs in, stores the session and goes home", async () => {
const session = makeSession({ username: "ann" });
route("POST /api/users/login", () => wire(session));
const user = setup();
expect(document.title).toMatch(/^Log in · /);
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
await screen.findByText("home page");
expect(calls("POST /api/users/login")[0].body).toEqual({ username: "ann", password: "hunter22" });
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username).toBe("ann");
});
test("both fields are required", async () => {
const user = setup();
// The header has a "Log in" button too; this is the form's.
await user.click(document.querySelector<HTMLButtonElement>(".auth-card button[type=submit]")!);
expect(calls("POST /api/users/login")).toHaveLength(0);
expect(screen.getByPlaceholderText("Username")).toHaveAttribute("aria-invalid", "true");
});
test("shows the server's refusal", async () => {
route("POST /api/users/login", () => fail("Invalid credentials", 401));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Login failed");
expect(alert).toHaveTextContent("Invalid credentials");
expect(currentPath()).toBe("/login");
});
test("says when the server can't be reached", async () => {
route("POST /api/users/login", () => new TypeError("Failed to fetch"));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
});
test("the button says so while logging in", async () => {
const pending = deferred();
route("POST /api/users/login", () => pending.promise);
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("button", { name: "Logging in…" })).toBeDisabled();
pending.resolve(new Response(JSON.stringify({ success: true, data: wire(makeSession()) })));
await screen.findByText("home page");
});
test("password reset request", async () => {
route("POST /api/users/request-password-reset", () => ({}));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(screen.getByPlaceholderText("Your email address"), " a@b.c {Enter}");
await screen.findByText(/you'll receive a reset link/);
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({ email: "a@b.c" });
});
test("a failed reset request is reported, not claimed as sent", async () => {
route("POST /api/users/request-password-reset", () => fail("Internal error", 500));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(screen.getByPlaceholderText("Your email address"), "a@b.c{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Request failed");
expect(screen.queryByText(/you'll receive a reset link/)).not.toBeInTheDocument();
});
});
describe("UserRegister", () => {
function setup(search = "?token=inv-1") {
const user = userEvent.setup();
renderWithContexts(<UserRegister />, {
route: `/register${search}`,
path: "/register",
extraRoutes: home,
});
return user;
}
test("no token: an invalid invite, no request", () => {
setup("");
expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite");
expect(calls("GET /api/invites/:t")).toHaveLength(0);
});
test("an unknown token is invalid", async () => {
route("GET /api/invites/:t", () => fail("nope", 404));
setup();
expect(screen.getByText("Checking invite…")).toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("already used");
});
test("registers with a valid invite", async () => {
route("GET /api/invites/:t", () => ({}));
const session = makeSession({ username: "new_one" });
route("POST /api/users/register", () => wire(session));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "new_one");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
await screen.findByText("home page");
expect(calls("POST /api/users/register")[0].body).toEqual({
username: "new_one",
password: "longenough",
inviteToken: "inv-1",
email: "n@o.pe",
});
expect(calls("GET /api/invites/:t")[0].params.t).toBe("inv-1");
});
test("validates the username and password length", async () => {
route("GET /api/invites/:t", () => ({}));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "bad name!");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "short{Enter}");
expect(await screen.findByText(/letters, numbers, or underscores/)).toBeInTheDocument();
expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
expect(calls("POST /api/users/register")).toHaveLength(0);
});
test("shows a server refusal (username taken)", async () => {
route("GET /api/invites/:t", () => ({}));
route("POST /api/users/register", () => fail("Username already taken", 409));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "taken");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Username already taken");
});
});
describe("ResetPassword", () => {
function setup(search = "?token=rst") {
const user = userEvent.setup();
renderWithContexts(<ResetPassword />, {
route: `/reset-password${search}`,
path: "/reset-password",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
return user;
}
test("no token: an invalid link", () => {
setup("");
expect(screen.getByRole("heading", { name: "Invalid link" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login");
});
test("the two passwords must match", async () => {
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "different");
expect(await screen.findByText("Passwords do not match")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set new password" }));
expect(calls("POST /api/users/reset-password")).toHaveLength(0);
});
test("sets the password, then offers login", async () => {
route("POST /api/users/reset-password", () => ({}));
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
await screen.findByRole("heading", { name: "Password updated" });
expect(calls("POST /api/users/reset-password")[0].body).toEqual({ token: "rst", newPassword: "longenough" });
await user.click(screen.getByRole("button", { name: "Go to login" }));
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("an expired token is reported", async () => {
route("POST /api/users/reset-password", () => fail("Reset link expired", 400));
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Reset link expired");
});
});
describe("route guards and 404", () => {
test("guest-only pages send members home", () => {
renderWithContexts(<RestrictedGuest><p>form</p></RestrictedGuest>, {
session: makeSession(),
route: "/login",
path: "/login",
extraRoutes: home,
});
expect(screen.getByText("home page")).toBeInTheDocument();
});
test("member-only pages send guests to login", () => {
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
route: "/notifications",
path: "/notifications",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("each guard lets the right people through", () => {
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
session: makeSession(),
});
expect(screen.getByText("secret")).toBeInTheDocument();
});
test("NotFound", async () => {
renderWithContexts(<NotFound />, { route: "/nope" });
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^Page not found/));
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute("href", "/");
});
});

4
src/test/po.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.po" {
import type { Messages } from "@lingui/core";
export const messages: Messages;
}

213
src/test/render.tsx Normal file
View File

@@ -0,0 +1,213 @@
import { type ReactElement, type ReactNode, useState } from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import type { AuthResponse, Category } from "../model.ts";
import { AuthContext } from "../contexts/AuthContext.ts";
import { AuthProvider } from "../contexts/AuthProvider.tsx";
import { WSContext, type WSContextValue } from "../contexts/WSContext.ts";
import { WSProvider } from "../contexts/WSProvider.tsx";
import { FollowContext, type FollowContextValue } from "../contexts/FollowContext.ts";
import { FollowProvider } from "../contexts/FollowProvider.tsx";
import {
CategoriesContext,
type CategoriesContextValue,
} from "../contexts/CategoriesContext.ts";
import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
import { PlayerContext, type PlayerContextValue } from "../contexts/PlayerContext.ts";
import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
import { ChatProvider } from "../contexts/ChatProvider.tsx";
import { route } from "./fetch.ts";
import { storeSession } from "./fixtures.ts";
/** Renders the current location into the DOM, for asserting navigation. */
export function LocationProbe() {
const loc = useLocation();
return (
<div hidden data-testid="location" data-state={JSON.stringify(loc.state ?? null)}>
{loc.pathname + loc.search}
</div>
);
}
/** The path the probe last saw. */
export function currentPath(): string {
return document.querySelector('[data-testid="location"]')?.textContent ?? "";
}
interface RouterOptions {
/** Initial URL, or several for a history stack (the last is current). */
route?: string | string[];
/** Route pattern the element is mounted on, for useParams. */
path?: string;
/** Extra routes to mount next to it, e.g. a redirect target. */
extraRoutes?: ReactNode;
}
function Router(
{ children, route = "/", path = "*", extraRoutes }: RouterOptions & {
children: ReactNode;
},
) {
const entries = Array.isArray(route) ? route : [route];
return (
<MemoryRouter initialEntries={entries} initialIndex={entries.length - 1}>
<Routes>
<Route path={path} element={children} />
{extraRoutes}
</Routes>
<LocationProbe />
</MemoryRouter>
);
}
// ── Stubbed contexts ─────────────────────────────────────────────────────────
export interface StubOptions extends RouterOptions {
session?: AuthResponse | null;
ws?: Partial<WSContextValue>;
follows?: Partial<FollowContextValue>;
categories?: Category[] | Partial<CategoriesContextValue>;
player?: Partial<PlayerContextValue>;
}
function StubAuth(
{ session, children }: { session: AuthResponse | null; children: ReactNode },
) {
const [authResponse, setAuthResponse] = useState(session);
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
);
}
function categoriesValue(
c: StubOptions["categories"],
): CategoriesContextValue {
const list = Array.isArray(c) ? c : c?.categories ?? [];
return {
categories: list,
byId: new Map(list.map((x) => [x.id, x])),
isLoaded: true,
refresh: async () => {},
...(Array.isArray(c) ? {} : c),
};
}
/** A context's default value — what a consumer outside any provider gets.
* React keeps it on the context object; there is no public accessor. */
export function defaultOf<T>(context: React.Context<T>): T {
return (context as unknown as { _currentValue: T })._currentValue;
}
/**
* Renders `ui` with context values supplied directly — no network, no socket.
* For components whose inputs are the contexts they read.
*/
export function renderWithContexts(
ui: ReactElement,
opts: StubOptions = {},
renderOptions?: RenderOptions,
) {
const defaultWS = defaultOf(WSContext);
const defaultFollows = defaultOf(FollowContext);
const defaultPlayer = defaultOf(PlayerContext);
// A wrapper rather than one tree, so `rerender(ui)` keeps the contexts.
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nProvider i18n={i18n}>
<StubAuth session={opts.session ?? null}>
<PlayerContext.Provider value={{ ...defaultPlayer, ...opts.player }}>
<WSContext.Provider value={{ ...defaultWS, ...opts.ws }}>
<FollowContext.Provider
value={{ ...defaultFollows, isLoaded: true, ...opts.follows }}
>
<CategoriesContext.Provider value={categoriesValue(opts.categories)}>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>
</WSContext.Provider>
</PlayerContext.Provider>
</StubAuth>
</I18nProvider>
);
return render(ui, { ...renderOptions, wrapper: Wrapper });
}
// ── The real providers ───────────────────────────────────────────────────────
export interface AppOptions extends RouterOptions {
/** Signed in as this session (stored before mount, as after a login). */
session?: AuthResponse | null;
categories?: Category[];
}
/** The routes every provider tree calls on mount, as fallbacks: a test's
* own routes for the same paths win, whenever they were added. */
export function defaultRoutes(
{ session, categories = [] }: Pick<AppOptions, "session" | "categories"> = {},
) {
const fallback = { fallback: true };
route(
"GET /api/categories",
() => categories.map((c) => JSON.parse(JSON.stringify(c))),
fallback,
);
route("GET /api/follows/status", () => ({
followedUserIds: [],
followedPlaylistIds: [],
}), fallback);
// Standing, not fallbacks: a page test's own `GET /api/users/:u` would
// otherwise answer for "me" and hand the session someone else's role.
route(
"GET /api/users/me",
() => JSON.parse(JSON.stringify(session?.user ?? null)),
);
route("GET /api/users/me/media-token", () => ({ mediaToken: "media-token" }));
}
/**
* Renders `ui` inside the same provider tree as App — real auth, socket (a
* FakeWebSocket), follows, categories, player and chat — over a memory router.
* Register routes the page needs with `route()` before or after calling this;
* the defaults for the providers' own requests are installed first so a test
* can override them.
*/
export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
if (opts.session) storeSession(opts.session);
defaultRoutes(opts);
const { route: initial = "/", path = "*", extraRoutes } = opts;
const entries = Array.isArray(initial) ? initial : [initial];
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nProvider i18n={i18n}>
<ThemeProvider>
<AuthProvider>
<PlayerProvider>
<WSProvider>
<FollowProvider>
<CategoriesProvider>
<MemoryRouter
initialEntries={entries}
initialIndex={entries.length - 1}
>
<ChatProvider>
<Routes>
<Route path={path} element={children} />
{extraRoutes}
</Routes>
<LocationProbe />
</ChatProvider>
</MemoryRouter>
</CategoriesProvider>
</FollowProvider>
</WSProvider>
</PlayerProvider>
</AuthProvider>
</ThemeProvider>
</I18nProvider>
);
return render(ui, { wrapper: Wrapper });
}

95
src/test/setup.ts Normal file
View File

@@ -0,0 +1,95 @@
// Runs before every test file: DOM matchers, the English catalog, and the
// browser APIs jsdom leaves out that the app reaches for at render time.
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, beforeEach, vi } from "vitest";
import { i18n } from "@lingui/core";
import { messages } from "../locales/en.po";
import { FakeWebSocket } from "./ws.ts";
import { resetFetch } from "./fetch.ts";
// Lazy chunks and the app's own debounces (a link preview waits 600ms) make
// the default 1s too tight once every file runs in parallel.
configure({ asyncUtilTimeout: 4000 });
i18n.load("en", messages);
i18n.activate("en");
class FakeIntersectionObserver {
static instances: FakeIntersectionObserver[] = [];
elements = new Set<Element>();
callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
FakeIntersectionObserver.instances.push(this);
}
observe(el: Element) {
this.elements.add(el);
}
unobserve(el: Element) {
this.elements.delete(el);
}
disconnect() {
this.elements.clear();
}
takeRecords() {
return [];
}
}
class FakeResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
Object.assign(globalThis, {
IntersectionObserver: FakeIntersectionObserver,
ResizeObserver: FakeResizeObserver,
});
if (!globalThis.matchMedia) {
globalThis.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as MediaQueryList;
}
Element.prototype.scrollIntoView ??= function () {};
Element.prototype.animate ??= function () {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation;
};
Element.prototype.getAnimations ??= () => [];
globalThis.scrollTo = () => {};
HTMLMediaElement.prototype.play = function () {
return Promise.resolve();
};
HTMLMediaElement.prototype.pause = function () {};
HTMLMediaElement.prototype.load = function () {};
// The emoji picker probes emoji support on a canvas; jsdom has none.
HTMLCanvasElement.prototype.getContext = (() => null) as never;
// jsdom has no layout; execCommand("insertText") is how the editor types.
document.execCommand ??= () => false;
beforeEach(() => {
resetFetch();
FakeWebSocket.reset();
vi.stubGlobal("WebSocket", FakeWebSocket);
FakeIntersectionObserver.instances = [];
});
afterEach(() => {
cleanup();
localStorage.clear();
sessionStorage.clear();
document.documentElement.removeAttribute("style");
});
export { FakeIntersectionObserver };

View File

@@ -0,0 +1,171 @@
import { describe, expect, test } from "vitest";
import {
composeJournal,
hasQuote,
hasThumbnail,
type JournalShape,
} from "../../utils/journalLayout.ts";
import { hotScore } from "../../utils/hotScore.ts";
import type { Dump } from "../../model.ts";
import { makeDump } from "../fixtures.ts";
const HOUR = 3_600_000;
const SPAN: Record<JournalShape, [number, number]> = {
feature: [2, 2],
wide: [2, 1],
tall: [1, 2],
square: [1, 1],
};
/** Replays the layout onto a 4-column grid, failing on overlap. Returns rows. */
function tile(shapes: JournalShape[]): boolean[][] {
const grid: boolean[][] = [];
const row = (r: number) => (grid[r] ??= [false, false, false, false]);
let cursor = 0;
for (const shape of shapes) {
while (row(Math.floor(cursor / 4))[cursor % 4]) cursor++;
const r = Math.floor(cursor / 4), c = cursor % 4;
const [w, h] = SPAN[shape];
for (let dr = 0; dr < h; dr++) {
for (let dc = 0; dc < w; dc++) {
if (c + dc >= 4) throw new Error(`${shape} overflows at ${r},${c}`);
if (row(r + dr)[c + dc]) throw new Error(`overlap at ${r + dr},${c + dc}`);
row(r + dr)[c + dc] = true;
}
}
}
return grid;
}
function feed(n: number, withArt: (i: number) => boolean): Dump[] {
return Array.from({ length: n }, (_, i) =>
makeDump({
id: `d${i}`,
// Strictly decreasing heat, so the ranking is the index order.
createdAt: new Date(Date.now() - (i + 1) * HOUR),
richContent: withArt(i)
? { type: "link", url: "https://x", thumbnailUrl: "https://x/t.png" }
: undefined,
}));
}
describe("hotScore", () => {
test("more votes is hotter, older is colder", () => {
const now = makeDump({ createdAt: new Date(), voteCount: 0 });
const voted = { ...now, voteCount: 5 };
const old = { ...now, createdAt: new Date(Date.now() - 48 * HOUR) };
expect(hotScore(voted)).toBeGreaterThan(hotScore(now));
expect(hotScore(old)).toBeLessThan(hotScore(now));
});
});
describe("hasThumbnail / hasQuote", () => {
test("image and video files have art; other files don't", () => {
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "image/png" })))
.toBe(true);
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "video/mp4" })))
.toBe(true);
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "audio/mpeg" })))
.toBe(false);
});
test("an uploaded thumbnail or a rich preview image counts", () => {
expect(hasThumbnail(makeDump({ thumbnailMime: "image/webp" }))).toBe(true);
expect(
hasThumbnail(
makeDump({ richContent: { type: "x", url: "u", thumbnailUrl: "t" } }),
),
).toBe(true);
expect(hasThumbnail(makeDump())).toBe(false);
});
test("a quote needs non-blank text", () => {
expect(hasQuote(makeDump({ comment: " " }))).toBe(false);
expect(hasQuote(makeDump({ comment: "yes" }))).toBe(true);
});
});
describe("composeJournal", () => {
test("nothing in, nothing out", () => {
expect(composeJournal([])).toEqual([]);
});
test("sorts by heat and leads with a feature when the top dump has art", () => {
const dumps = feed(5, () => true).reverse();
const [block] = composeJournal(dumps);
expect(block.entries.map((e) => e.dump.id)).toEqual([
"d0",
"d1",
"d2",
"d3",
"d4",
]);
expect(block.entries[0].shape).toBe("feature");
});
test("a bare note on top gives way to art from just behind it", () => {
const dumps = feed(6, (i) => i === 2);
const [block] = composeJournal(dumps);
expect(block.entries[0].dump.id).toBe("d2");
expect(block.entries[0].shape).toBe("feature");
});
test("art too far down doesn't jump the queue", () => {
const dumps = feed(8, (i) => i === 5);
const [block] = composeJournal(dumps);
expect(block.entries[0].dump.id).toBe("d0");
expect(block.entries[0].shape).toBe("wide");
});
test("only dumps with art take the big footprints", () => {
const blocks = composeJournal(feed(40, (i) => i % 3 === 0));
for (const { entries } of blocks) {
for (const { dump, shape } of entries) {
if (shape === "tall" || shape === "feature") {
expect(hasThumbnail(dump)).toBe(true);
}
}
}
});
for (const [name, art] of [
["all art", () => true],
["no art", () => false],
["mixed", (i: number) => i % 2 === 0],
["sparse", (i: number) => i % 7 === 0],
] as const) {
test(`tiles without gaps or overlap (${name})`, () => {
for (const n of [1, 2, 3, 7, 13, 25, 60]) {
const blocks = composeJournal(feed(n, art));
expect(blocks.flatMap((b) => b.entries)).toHaveLength(n);
for (const block of blocks) {
const grid = tile(block.entries.map((e) => e.shape));
// Every row but the last closes flush.
grid.slice(0, -1).forEach((r) => expect(r).toEqual([true, true, true, true]));
}
}
});
}
test("long feeds break into blocks, each opening on a hero", () => {
const blocks = composeJournal(feed(60, () => true));
expect(blocks.length).toBeGreaterThan(1);
for (const block of blocks) {
expect(["feature", "wide"]).toContain(block.entries[0].shape);
expect(block.key).toBe(block.entries[0].dump.id);
}
});
test("no break leaves a fragment at the tail", () => {
const blocks = composeJournal(feed(60, () => true));
expect(blocks.at(-1)!.entries.length).toBeGreaterThanOrEqual(6);
});
test("appending a page doesn't reshuffle what came before", () => {
const first = composeJournal(feed(30, (i) => i % 2 === 0));
const more = composeJournal(feed(60, (i) => i % 2 === 0));
const flat = (bs: typeof first) =>
bs.flatMap((b) => b.entries.map((e) => `${e.dump.id}:${e.shape}`));
// Shapes near the old tail may grow once more cards follow, but the order
// and the opening stretch are fixed.
expect(flat(more).slice(0, 20)).toEqual(flat(first).slice(0, 20));
});
});

166
src/test/utils/misc.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { can } from "../../utils/permissions.ts";
import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
import { relativeTime } from "../../utils/relativeTime.ts";
import {
hueFromString,
initialsFor,
isSafeHex,
tintFor,
} from "../../utils/thumbnailTint.ts";
import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
normalizeUrl,
playlistUrl,
} from "../../utils/urls.ts";
import { API_URL } from "../../config/api.ts";
import { i18n } from "../../i18n.ts";
describe("can", () => {
test("admins can do everything, moderators moderate, users nothing", () => {
expect(can({ role: "admin" }, "category:manage")).toBe(true);
expect(can({ role: "admin" }, "user:manage")).toBe(true);
expect(can({ role: "moderator" }, "dump:moderate")).toBe(true);
expect(can({ role: "moderator" }, "chat:moderate")).toBe(true);
expect(can({ role: "moderator" }, "category:manage")).toBe(false);
expect(can({ role: "user" }, "dump:moderate")).toBe(false);
});
test("no user, no permission", () => {
expect(can(null, "dump:moderate")).toBe(false);
expect(can(undefined, "dump:moderate")).toBe(false);
});
});
describe("proxyImageUrl", () => {
test("http images go through the proxy; https ones don't", () => {
expect(proxyImageUrl("http://img.test/a.png")).toBe(
`${API_URL}/api/proxy-image?url=${encodeURIComponent("http://img.test/a.png")}`,
);
expect(proxyImageUrl("https://img.test/a.png")).toBe(
"https://img.test/a.png",
);
});
test("force proxies https too", () => {
expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
"/api/proxy-image?url=",
);
});
test("relative, local and non-http URLs are left alone", () => {
expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
"http://localhost:8000/a.png",
);
expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe("http://127.0.0.1/a.png");
expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
"data:image/png;base64,AA",
);
});
});
describe("canProxyImage", () => {
test("third-party http(s) only", () => {
expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
expect(canProxyImage("/relative.png")).toBe(false);
expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
expect(canProxyImage("http://[bad")).toBe(false);
});
});
describe("relativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
});
afterEach(() => vi.useRealTimers());
const ago = (s: number) => new Date(Date.now() - s * 1000);
test("picks the largest sensible unit", () => {
expect(relativeTime(ago(0))).toBe("now");
expect(relativeTime(ago(30))).toBe("30 seconds ago");
expect(relativeTime(ago(120))).toBe("2 minutes ago");
expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago");
expect(relativeTime(ago(86400))).toBe("yesterday");
expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago");
expect(relativeTime(ago(60 * 86400))).toBe("2 months ago");
expect(relativeTime(ago(800 * 86400))).toBe("2 years ago");
});
test("follows the active locale", () => {
try {
i18n.load("fr", {});
i18n.activate("fr");
expect(relativeTime(ago(120))).toBe("il y a 2 minutes");
} finally {
i18n.activate("en");
}
});
});
describe("thumbnail tint", () => {
test("isSafeHex accepts only canonical #rrggbb", () => {
expect(isSafeHex("#a1b2c3")).toBe(true);
expect(isSafeHex("#A1B2C3")).toBe(false);
expect(isSafeHex("#abc")).toBe(false);
expect(isSafeHex("red")).toBe(false);
expect(isSafeHex("#000000;background:url(x)")).toBe(false);
expect(isSafeHex(undefined)).toBe(false);
});
test("hueFromString is stable and in range", () => {
expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
test("tintFor prefers the page colour, then the hostname sans www", () => {
expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
"#112233",
);
expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
hueFromString("a.b"),
);
expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
});
test("initialsFor takes the first letter or digit", () => {
expect(initialsFor(" bandcamp", undefined)).toBe("B");
expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y");
expect(initialsFor("«Élan»", undefined)).toBe("É");
expect(initialsFor("", "")).toBe("?");
});
});
describe("urls", () => {
test("dumpFileUrl versions by size and carries the token", () => {
expect(dumpFileUrl({ id: "d1", fileSize: 42 })).toBe(
`${API_URL}/api/files/d1?v=42`,
);
expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
`${API_URL}/api/files/d1?v=0&token=t%20o%2Bk`,
);
});
test("dumpThumbnailUrl", () => {
expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
`${API_URL}/api/thumbnails/d1?token=tk`,
);
});
test("normalizeUrl adds a scheme when there's none", () => {
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a");
expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
expect(normalizeUrl(" ")).toBe("");
});
test("slug beats id in page URLs", () => {
expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
});
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, test } from "vitest";
import {
deserializeAuthResponse,
deserializeChatMessage,
deserializeComment,
deserializeDump,
deserializeNotification,
deserializePlaylistMembership,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../../model.ts";
describe("parseAPIResponse", () => {
test("accepts either envelope", () => {
expect(parseAPIResponse({ success: true, data: 1 })).toEqual({
success: true,
data: 1,
});
expect(parseAPIResponse({ success: false, error: { message: "x" } }))
.toMatchObject({ success: false });
});
test("rejects anything else", () => {
expect(() => parseAPIResponse(null)).toThrow(/Unexpected/);
expect(() => parseAPIResponse({ data: 1 })).toThrow(/Unexpected/);
expect(() => parseAPIResponse("ok")).toThrow(/Unexpected/);
});
});
describe("deserializers", () => {
const createdAt = "2026-03-01T10:00:00.000Z";
const updatedAt = "2026-03-02T10:00:00.000Z";
test("revive createdAt and an optional updatedAt", () => {
const d = deserializeDump({
id: "d",
kind: "url",
title: "t",
userId: "u",
createdAt,
voteCount: 0,
commentCount: 0,
isPrivate: false,
});
expect(d.createdAt).toEqual(new Date(createdAt));
expect(d.updatedAt).toBeUndefined();
const c = deserializeComment({
id: "c",
dumpId: "d",
userId: "u",
body: "b",
createdAt,
updatedAt,
deleted: false,
likeCount: 0,
authorUsername: "a",
});
expect(c.updatedAt).toEqual(new Date(updatedAt));
const m = deserializeChatMessage({
id: "m",
userId: "u",
body: "b",
createdAt,
authorUsername: "a",
});
expect(m.createdAt.getTime()).toBe(Date.parse(createdAt));
const n = deserializeNotification({
id: "n",
userId: "u",
type: "user_followed",
data: { followerId: "f", followerUsername: "fan" },
read: false,
createdAt,
});
expect(n.createdAt).toBeInstanceOf(Date);
});
test("nested values are revived too", () => {
const auth = deserializeAuthResponse({
token: "t",
user: { id: "u", username: "u", role: "user", createdAt },
});
expect(auth.user.createdAt).toBeInstanceOf(Date);
const p = deserializePlaylistWithDumps({
id: "p",
userId: "u",
title: "t",
isPublic: true,
createdAt,
dumps: [{
id: "d",
kind: "url",
title: "t",
userId: "u",
createdAt,
voteCount: 0,
commentCount: 0,
isPrivate: false,
}],
});
expect(p.dumps[0].createdAt).toBeInstanceOf(Date);
const mem = deserializePlaylistMembership({
playlist: { id: "p", userId: "u", title: "t", isPublic: true, createdAt },
hasDump: true,
});
expect(mem.playlist.createdAt).toBeInstanceOf(Date);
expect(mem.hasDump).toBe(true);
});
});

View File

@@ -0,0 +1,197 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { resolveBandcampQueue } from "../../utils/bandcamp.ts";
import {
isStreamStale,
resolveStreamQueue,
STREAM_STALE_MS,
} from "../../utils/streamSources.ts";
import {
embedListId,
fetchPlaylistTracks,
withJsApi,
} from "../../utils/youtube.ts";
import { extractPeaks } from "../../utils/waveform.ts";
import { calls, fail, route } from "../fetch.ts";
import type { PlayerItem } from "../../contexts/PlayerContext.ts";
import { playerItemKey } from "../../contexts/PlayerContext.ts";
afterEach(() => vi.useRealTimers());
const album = {
sourceUrl: "https://artist.bandcamp.com/album/a",
itemType: "album",
title: "A",
artist: "Artist",
artworkUrl: "https://f4.bcbits.com/a.jpg",
resolvedAt: 1000,
tracks: [
{ index: 0, trackNum: 1, title: "One", duration: 100, streamUrl: "https://s/1", streamable: true, capped: false },
{ index: 1, trackNum: 2, title: "Two", streamUrl: null, streamable: false, capped: true },
{ index: 2, trackNum: 3, title: "Three", duration: 50, streamUrl: "https://s/3", streamable: true, capped: false },
],
};
describe("resolveBandcampQueue", () => {
test("turns the streamable tracks into a queue", async () => {
route("GET /api/bandcamp/tracks", () => album);
const items = await resolveBandcampQueue(album.sourceUrl, {
dumpHref: "/dumps/x",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
title: "One",
subtitle: "Artist",
trackNum: 1,
duration: 100,
artworkUrl: album.artworkUrl,
dumpHref: "/dumps/x",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: 1000,
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items[1]).toMatchObject({ resolveIndex: 2 });
const [req] = calls("GET /api/bandcamp/tracks");
expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
expect(req.url.searchParams.has("force")).toBe(false);
});
test("force asks the server to skip its cache", async () => {
route("GET /api/bandcamp/tracks", () => album);
await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("falls back to the given artwork", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
const [item] = await resolveBandcampQueue(album.sourceUrl, {
fallbackArtworkUrl: "https://mine/art.png",
});
expect(item.artworkUrl).toBe("https://mine/art.png");
});
test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
route("GET /api/bandcamp/tracks", () => ({
...album,
tracks: [album.tracks[1]],
}));
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/);
});
});
describe("stream sources", () => {
const stream: Extract<PlayerItem, { kind: "stream" }> = {
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: Date.now(),
artworkUrl: "https://old/art.png",
dumpHref: "/dumps/x",
};
test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
const items = await resolveStreamQueue(stream);
expect(items[0].artworkUrl).toBe("https://old/art.png");
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("an unknown stream type has no resolver", async () => {
await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects
.toThrow(/no resolver/);
});
test("isStreamStale after the window", () => {
vi.useFakeTimers();
vi.setSystemTime(10 * STREAM_STALE_MS);
expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(false);
expect(
isStreamStale({ ...stream, resolvedAt: Date.now() - STREAM_STALE_MS - 1 }),
).toBe(true);
});
test("playerItemKey ignores the signed URL", () => {
expect(playerItemKey(stream)).toBe(
playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
);
expect(playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }))
.toBe("file:f");
expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
.toBe("embed:e");
});
});
describe("youtube", () => {
test("embedListId reads ?list= from either embed shape", () => {
expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
.toBe("PL1");
expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe("PL2");
expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
expect(embedListId("nonsense")).toBeNull();
});
test("withJsApi adds the API parameters and our origin", () => {
const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL"));
expect(url.searchParams.get("enablejsapi")).toBe("1");
expect(url.searchParams.get("origin")).toBe(location.origin);
expect(url.searchParams.get("list")).toBe("PL");
expect(withJsApi("bad url")).toBe("bad url");
});
test("fetchPlaylistTracks", async () => {
route("GET /api/youtube/playlist", (req) =>
req.url.searchParams.get("list") === "PL"
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
: { tracks: [] });
await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
{ videoId: "a", title: "A", duration: 3 },
]);
await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
route("GET /api/youtube/playlist", () => fail("x", 500));
await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
});
});
describe("extractPeaks", () => {
test("decodes once per URL and takes each block's peak", async () => {
const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
const decode = vi.fn(async () => ({ getChannelData: () => data }));
const close = vi.fn();
vi.stubGlobal(
"AudioContext",
class {
decodeAudioData = decode;
close = close;
},
);
route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
expect(close).toHaveBeenCalled();
await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect(decode).toHaveBeenCalledTimes(1);
expect(calls("GET /api/files/:id")).toHaveLength(1);
});
test("a failed fetch throws", async () => {
route("GET /api/files/:id", () => fail("gone", 404));
await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
.toThrow(/404/);
});
});

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// sound.ts keeps its AudioContext and chat throttle at module level, so each
// test loads a fresh copy against a fake Web Audio.
class FakeAudioContext {
static instances: FakeAudioContext[] = [];
state: "suspended" | "running" = "running";
currentTime = 0;
destination = {};
oscillators = 0;
constructor() {
FakeAudioContext.instances.push(this);
}
resume = vi.fn(async () => {
this.state = "running";
});
createOscillator() {
this.oscillators++;
const node = {
type: "",
frequency: { setValueAtTime() {} },
connect: () => ({ connect() {} }),
start() {},
stop() {},
};
return node;
}
createGain() {
return {
gain: { setValueAtTime() {}, exponentialRampToValueAtTime() {} },
};
}
}
async function load() {
vi.resetModules();
return await import("../../utils/sound.ts");
}
const notes = () =>
FakeAudioContext.instances.reduce((n, c) => n + c.oscillators, 0);
beforeEach(() => {
FakeAudioContext.instances = [];
vi.stubGlobal("AudioContext", FakeAudioContext);
vi.useFakeTimers();
vi.setSystemTime(1_000_000);
});
afterEach(() => vi.useRealTimers());
describe("sounds preference", () => {
test("on by default, stored as on/off", async () => {
const s = await load();
expect(s.readSoundsEnabled()).toBe(true);
s.writeSoundsEnabled(false);
expect(localStorage.getItem("sounds")).toBe("off");
expect(s.readSoundsEnabled()).toBe(false);
});
test("unavailable storage leaves sounds on", async () => {
const s = await load();
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
expect(s.readSoundsEnabled()).toBe(true);
});
});
describe("playChime", () => {
test("a notification plays two notes, a chat message one", async () => {
const s = await load();
s.playChime("notify", "n1");
expect(notes()).toBe(2);
s.playChime("chat", "c1");
expect(notes()).toBe(3);
});
test("silent when sounds are off", async () => {
const s = await load();
s.writeSoundsEnabled(false);
s.playChime("notify", "n1");
expect(notes()).toBe(0);
});
test("one chime per event across tabs", async () => {
const s = await load();
s.playChime("notify", "same");
s.playChime("notify", "same");
expect(notes()).toBe(2);
expect(localStorage.getItem("sounds-last-claim")).toBe("notify:same");
});
test("chat blips are throttled", async () => {
const s = await load();
s.playChime("chat", "a");
s.playChime("chat", "b");
expect(notes()).toBe(1);
vi.advanceTimersByTime(4_001);
s.playChime("chat", "c");
expect(notes()).toBe(2);
});
test("a suspended context stays quiet until a gesture unlocks it", async () => {
const s = await load();
s.playChime("notify", "warmup"); // creates the context
const ctx = FakeAudioContext.instances[0];
ctx.state = "suspended";
s.playChime("notify", "n2");
expect(ctx.oscillators).toBe(2);
globalThis.dispatchEvent(new Event("keydown"));
expect(ctx.resume).toHaveBeenCalled();
});
test("previewChime resumes and plays regardless of the preference", async () => {
const s = await load();
s.writeSoundsEnabled(false);
s.previewChime("chat");
expect(notes()).toBe(1);
FakeAudioContext.instances[0].state = "suspended";
s.previewChime("notify");
await vi.runAllTimersAsync();
expect(notes()).toBe(3);
});
test("no Web Audio, no crash", async () => {
vi.stubGlobal("AudioContext", class {
constructor() {
throw new Error("unsupported");
}
});
const s = await load();
expect(() => s.playChime("notify", "x")).not.toThrow();
expect(() => s.previewChime("chat")).not.toThrow();
});
});

View File

@@ -0,0 +1,183 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
AUTH_STORAGE_KEY,
forgetStoredSession,
isTokenExpired,
readStoredSession,
storeSession,
} from "../../utils/authStorage.ts";
import {
clearDumpDraft,
EMPTY_DUMP_DRAFT,
isDumpDraftEmpty,
loadDumpDraft,
saveDumpDraft,
} from "../../utils/dumpDraft.ts";
import {
isDumpVisited,
isPlaylistVisited,
isRecent,
markDumpVisited,
markPlaylistVisited,
} from "../../utils/visited.ts";
import { makeSession, makeToken, wire } from "../fixtures.ts";
afterEach(() => vi.useRealTimers());
describe("isTokenExpired", () => {
test("reads exp from the payload", () => {
expect(isTokenExpired(makeToken({ expiresIn: 60 }))).toBe(false);
expect(isTokenExpired(makeToken({ expiresIn: -60 }))).toBe(true);
});
test("a token it can't read is treated as expired", () => {
expect(isTokenExpired("garbage")).toBe(true);
expect(isTokenExpired("a.b.c")).toBe(true);
});
test("no exp means it doesn't expire", () => {
const token = `x.${btoa(JSON.stringify({ sub: "u" }))}.y`;
expect(isTokenExpired(token)).toBe(false);
});
});
describe("stored session", () => {
test("round-trips, with dates revived", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
const read = readStoredSession();
expect(read?.user.username).toBe("ann");
expect(read?.user.createdAt).toBeInstanceOf(Date);
});
test("nothing stored", () => {
expect(readStoredSession()).toBeNull();
});
test("unparseable, expired and roleless sessions are discarded", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{not json");
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const expired = makeSession();
expired.token = makeToken({ expiresIn: -1 });
storeSession(expired);
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const legacy = wire(makeSession()) as unknown as {
user: { role?: string };
};
delete legacy.user.role;
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(legacy));
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession only forgets the session holding that token", () => {
const session = makeSession();
storeSession(session);
forgetStoredSession("some-older-token");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).not.toBeNull();
forgetStoredSession(session.token);
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession clears an unreadable entry", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{bad");
forgetStoredSession("anything");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});
describe("dump draft", () => {
const draft = {
...EMPTY_DUMP_DRAFT,
url: "https://a.b",
comment: "why",
};
test("an empty draft is empty; any content isn't", () => {
expect(isDumpDraftEmpty(EMPTY_DUMP_DRAFT)).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, url: " " })).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, isPublic: false })).toBe(
false,
);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, categoryIds: ["c"] })).toBe(
false,
);
});
test("a title from the preview isn't worth keeping; a typed one is", () => {
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, title: "Scraped" })).toBe(
true,
);
expect(
isDumpDraftEmpty({
...EMPTY_DUMP_DRAFT,
title: "Mine",
titleFromUser: true,
}),
).toBe(false);
});
test("save, load and clear", () => {
saveDumpDraft(draft);
expect(loadDumpDraft()).toEqual(draft);
clearDumpDraft();
expect(loadDumpDraft()).toBeNull();
});
test("saving an empty draft removes the stored one", () => {
saveDumpDraft(draft);
saveDumpDraft(EMPTY_DUMP_DRAFT);
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
});
test("a malformed stored draft is sanitised or ignored", () => {
localStorage.setItem(
"gerbeur.dumpDraft",
JSON.stringify({ url: 5, comment: "c", categoryIds: ["a", 3, null] }),
);
expect(loadDumpDraft()).toEqual({
...EMPTY_DUMP_DRAFT,
comment: "c",
categoryIds: ["a"],
});
localStorage.setItem("gerbeur.dumpDraft", "nope");
expect(loadDumpDraft()).toBeNull();
});
test("storage that throws never breaks posting", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceeded");
});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
expect(() => saveDumpDraft(draft)).not.toThrow();
expect(loadDumpDraft()).toBeNull();
});
});
describe("visited", () => {
test("dumps and playlists are tracked apart", () => {
markDumpVisited("a");
markPlaylistVisited("b");
expect(isDumpVisited("a")).toBe(true);
expect(isDumpVisited("b")).toBe(false);
expect(isPlaylistVisited("b")).toBe(true);
expect(isPlaylistVisited("a")).toBe(false);
});
test("a corrupt entry reads as nothing visited", () => {
localStorage.setItem("visited_dumps", "{");
expect(isDumpVisited("a")).toBe(false);
markDumpVisited("a");
expect(isDumpVisited("a")).toBe(true);
});
test("isRecent is a seven-day window", () => {
const day = 24 * 3600 * 1000;
expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
expect(isRecent(new Date(Date.now() - 8 * day))).toBe(false);
});
});

157
src/test/utils/text.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import { describe, expect, test } from "vitest";
import { insideCode, isEmojiOnly } from "../../utils/editorText.ts";
import { markdownToPlainText, mentionsUser } from "../../utils/chatText.ts";
import { charCountClass, overLimit } from "../../utils/charCount.ts";
import { formatBytes, titleFromFileName } from "../../utils/format.ts";
import { fmt } from "../../utils/duration.ts";
import { friendlyFetchError } from "../../utils/apiError.ts";
describe("insideCode", () => {
test("plain text is not code", () => {
expect(insideCode("hello @bob")).toBe(false);
});
test("an unclosed inline span is code", () => {
expect(insideCode("try `@bob")).toBe(true);
expect(insideCode("try `a` then `@bob")).toBe(true);
});
test("a closed inline span is not", () => {
expect(insideCode("try `a` @bob")).toBe(false);
});
test("an open fence is code, a closed one is not", () => {
expect(insideCode("```\n@bob")).toBe(true);
expect(insideCode("```js\nx\n```\n@bob")).toBe(false);
});
test("the fence line itself is not an inline span", () => {
expect(insideCode("text\n```\ncode\n```")).toBe(false);
});
test("backticks on an earlier line don't carry over", () => {
expect(insideCode("a ` b\n@bob")).toBe(false);
});
});
describe("isEmojiOnly", () => {
test("one to three emoji", () => {
expect(isEmojiOnly("🎉")).toBe(true);
expect(isEmojiOnly("👍👍")).toBe(true);
expect(isEmojiOnly(" 🎉 🎉 🎉 ")).toBe(true);
});
test("more than the max", () => {
expect(isEmojiOnly("🎉🎉🎉🎉")).toBe(false);
expect(isEmojiOnly("🎉🎉🎉🎉", 4)).toBe(true);
});
test("a ZWJ sequence, a skin tone and a flag count as one each", () => {
expect(isEmojiOnly("👨‍👩‍👧‍👦")).toBe(true);
expect(isEmojiOnly("👍🏽")).toBe(true);
expect(isEmojiOnly("🇫🇷🇫🇷🇫🇷")).toBe(true);
});
test("text, digits and symbols are not emoji", () => {
expect(isEmojiOnly("")).toBe(false);
expect(isEmojiOnly("hi 🎉")).toBe(false);
expect(isEmojiOnly("123")).toBe(false);
expect(isEmojiOnly("#")).toBe(false);
expect(isEmojiOnly("1🎉")).toBe(false);
});
});
describe("mentionsUser", () => {
test("case-insensitive whole-word match", () => {
expect(mentionsUser("hey @Alice!", "alice")).toBe(true);
expect(mentionsUser("hey @alicex", "alice")).toBe(false);
});
test("follows the server's rule: not in an email, a link or brackets", () => {
expect(mentionsUser("mail bob@alice", "alice")).toBe(false);
expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
expect(mentionsUser("(@alice)", "alice")).toBe(false);
});
test("any of several mentions", () => {
expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
});
});
describe("markdownToPlainText", () => {
test("links become their text, images their alt or a label", () => {
expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe(
"see this now",
);
expect(markdownToPlainText("![cat](a.png)", "Image")).toBe("cat");
expect(markdownToPlainText("![](a.png)", "Image")).toBe("Image");
});
test("markup characters and block markers go", () => {
expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe(
"Title quote bold code",
);
});
test("a body cut off mid-link doesn't show a half URL", () => {
expect(markdownToPlainText("look [here](https://exa", "img")).toBe(
"look here",
);
expect(markdownToPlainText("pic ![alt](https://exa", "Image")).toBe(
"pic alt",
);
expect(markdownToPlainText("pic ![](https://exa", "Image")).toBe(
"pic Image",
);
expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic");
});
test("whitespace collapses to one line", () => {
expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c");
});
});
describe("character limits", () => {
test("charCountClass warns from 85% and flags the limit", () => {
expect(charCountClass(10, 100)).toBe("");
expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
});
test("overLimit is strictly past it", () => {
expect(overLimit("abc", 3)).toBe(false);
expect(overLimit("abcd", 3)).toBe(true);
});
});
describe("formatBytes", () => {
test("B, KB and MB", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
});
});
describe("titleFromFileName", () => {
test("drops the extension and turns underscores into spaces", () => {
expect(titleFromFileName("holiday_photo-01.final.jpg")).toBe(
"holiday photo-01.final",
);
});
test("a dotfile keeps its name", () => {
expect(titleFromFileName(".gitignore")).toBe(".gitignore");
});
test("a name that is only separators falls back to itself", () => {
expect(titleFromFileName("___.txt")).toBe("___.txt");
});
});
describe("fmt", () => {
test("m:ss", () => {
expect(fmt(0)).toBe("0:00");
expect(fmt(65.9)).toBe("1:05");
expect(fmt(3600)).toBe("60:00");
});
test("non-finite reads as zero", () => {
expect(fmt(NaN)).toBe("0:00");
expect(fmt(Infinity)).toBe("0:00");
});
});
describe("friendlyFetchError", () => {
test("a TypeError is a connection problem", () => {
expect(friendlyFetchError(new TypeError("Failed to fetch"))).toMatch(
/Could not connect/,
);
});
test("other errors keep their message", () => {
expect(friendlyFetchError(new Error("Nope"))).toBe("Nope");
expect(friendlyFetchError("weird")).toBe("An unexpected error occurred.");
});
});

101
src/test/ws.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { IncomingWSMessage } from "../model.ts";
import { act } from "@testing-library/react";
/**
* Stands in for the browser WebSocket. Every socket the app opens is kept in
* `FakeWebSocket.instances`; a test opens it, feeds it server messages and
* reads back what the client sent.
*/
export class FakeWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
static reset() {
FakeWebSocket.instances = [];
}
/** The newest socket the app opened. */
static get last(): FakeWebSocket {
const ws = FakeWebSocket.instances.at(-1);
if (!ws) throw new Error("no WebSocket was opened");
return ws;
}
readyState = FakeWebSocket.CONNECTING;
sent: string[] = [];
onopen: ((e: Event) => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
onclose: ((e: CloseEvent) => void) | null = null;
onerror: ((e: Event) => void) | null = null;
url: string;
constructor(url: string) {
super();
this.url = url;
FakeWebSocket.instances.push(this);
}
send(data: string) {
if (this.readyState !== FakeWebSocket.OPEN) {
throw new Error("send on a socket that isn't open");
}
this.sent.push(data);
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.(new Event("close") as CloseEvent);
}
/** What the client sent, parsed. */
get messages(): Array<Record<string, unknown>> {
return this.sent.map((s) => JSON.parse(s));
}
// ── Server side ───────────────────────────────────────────────────────────
open() {
act(() => {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open"));
});
}
receive(msg: IncomingWSMessage | Record<string, unknown>) {
act(() => {
this.onmessage?.(
new MessageEvent("message", { data: JSON.stringify(msg) }),
);
});
}
receiveRaw(data: string) {
act(() => {
this.onmessage?.(new MessageEvent("message", { data }));
});
}
/** The server drops the connection. */
drop() {
act(() => this.close());
}
/** Opens the socket and sends a welcome, as the server does on connect. */
welcome(overrides: Partial<Extract<IncomingWSMessage, { type: "welcome" }>> = {}) {
this.open();
this.receive({
type: "welcome",
users: [],
myVotes: [],
myCommentLikes: [],
unreadNotificationCount: 0,
unreadChatCount: 0,
...overrides,
});
}
}