v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
304
src/test/components/ChatModal.test.tsx
Normal file
304
src/test/components/ChatModal.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
200
src/test/components/CommentThread.test.tsx
Normal file
200
src/test/components/CommentThread.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
||||
336
src/test/components/DumpCreateModal.test.tsx
Normal file
336
src/test/components/DumpCreateModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
105
src/test/components/Modal.test.tsx
Normal file
105
src/test/components/Modal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
295
src/test/components/TextEditor.test.tsx
Normal file
295
src/test/components/TextEditor.test.tsx
Normal 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: ")
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
292
src/test/components/buttons.test.tsx
Normal file
292
src/test/components/buttons.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
307
src/test/components/content.test.tsx
Normal file
307
src/test/components/content.test.tsx
Normal 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("");
|
||||
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy");
|
||||
full.unmount();
|
||||
md(" 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();
|
||||
});
|
||||
});
|
||||
430
src/test/components/header.test.tsx
Normal file
430
src/test/components/header.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
225
src/test/components/media.test.tsx
Normal file
225
src/test/components/media.test.tsx
Normal 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";
|
||||
242
src/test/components/player.test.tsx
Normal file
242
src/test/components/player.test.tsx
Normal 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("/");
|
||||
});
|
||||
});
|
||||
|
||||
370
src/test/contexts/PlayerProvider.test.tsx
Normal file
370
src/test/contexts/PlayerProvider.test.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, useContext, useState } from "react";
|
||||
import { AuthContext } from "../../contexts/AuthContext.ts";
|
||||
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
|
||||
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
|
||||
import type { AuthResponse } from "../../model.ts";
|
||||
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
|
||||
import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import { makeSession } from "../fixtures.ts";
|
||||
|
||||
const embed = (n: number): PlayerItem => ({
|
||||
kind: "embed",
|
||||
embedUrl: `https://www.youtube.com/embed/v${n}`,
|
||||
type: "youtube",
|
||||
title: `Video ${n}`,
|
||||
});
|
||||
const file = (n: number): PlayerItem => ({
|
||||
kind: "file",
|
||||
fileUrl: `http://localhost:8000/api/files/f${n}?token=secret`,
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
const stream = (i: number, over: Partial<PlayerItem> = {}) =>
|
||||
({
|
||||
kind: "stream",
|
||||
type: "bandcamp",
|
||||
streamUrl: `https://s/${i}`,
|
||||
resolveUrl: "https://a.bandcamp.com/album/x",
|
||||
resolveIndex: i,
|
||||
resolvedAt: Date.now(),
|
||||
title: `Track ${i}`,
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
|
||||
...over,
|
||||
}) as Extract<PlayerItem, { kind: "stream" }>;
|
||||
|
||||
const albumResponse = (indexes: number[]) => ({
|
||||
sourceUrl: "https://a.bandcamp.com/album/x",
|
||||
itemType: "album",
|
||||
artist: "Band",
|
||||
resolvedAt: Date.now(),
|
||||
tracks: indexes.map((i) => ({
|
||||
index: i,
|
||||
title: `Track ${i}`,
|
||||
streamUrl: `https://fresh/${i}`,
|
||||
streamable: true,
|
||||
capped: false,
|
||||
})),
|
||||
});
|
||||
|
||||
let setSession: (s: AuthResponse | null) => void = () => {};
|
||||
function wrapperFor(initial: AuthResponse | null) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [authResponse, setAuthResponse] = useState(initial);
|
||||
setSession = setAuthResponse;
|
||||
return (
|
||||
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
|
||||
<PlayerProvider>{children}</PlayerProvider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function setup(session: AuthResponse | null = null) {
|
||||
const hook = renderHook(() => useContext(PlayerContext), {
|
||||
wrapper: wrapperFor(session),
|
||||
});
|
||||
return { hook, p: () => hook.result.current };
|
||||
}
|
||||
|
||||
const stored = () => JSON.parse(localStorage.getItem("player") ?? "null");
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("queue", () => {
|
||||
test("play() makes a one-item queue that autoplays", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().play(embed(1)));
|
||||
expect(p().current).toEqual(embed(1));
|
||||
expect(p().queue).toHaveLength(1);
|
||||
expect(p().autoplay).toBe(true);
|
||||
expect(p().hasNext).toBe(false);
|
||||
});
|
||||
|
||||
test("playQueue clamps the start; an empty queue is ignored", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([embed(1), embed(2)], 9));
|
||||
expect(p().queueIndex).toBe(1);
|
||||
act(() => p().playQueue([], 0));
|
||||
expect(p().queue).toHaveLength(2);
|
||||
act(() => p().playQueue([embed(1), embed(2)], -3));
|
||||
expect(p().queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
test("next / previous / playAt", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([embed(1), embed(2), embed(3)]));
|
||||
act(() => p().next());
|
||||
expect(p().queueIndex).toBe(1);
|
||||
expect(p().hasPrevious).toBe(true);
|
||||
act(() => p().playAt(2));
|
||||
expect(p().queueIndex).toBe(2);
|
||||
act(() => p().next());
|
||||
expect(p().queueIndex).toBe(2);
|
||||
act(() => p().playAt(7));
|
||||
expect(p().queueIndex).toBe(2);
|
||||
act(() => p().previous());
|
||||
expect(p().queueIndex).toBe(1);
|
||||
});
|
||||
|
||||
test("previous restarts the track past three seconds", () => {
|
||||
const { p } = setup();
|
||||
const seek = vi.fn();
|
||||
act(() => p().playQueue([embed(1), embed(2)], 1));
|
||||
p().seekRef.current = seek;
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().onTimeUpdate(10, 100));
|
||||
act(() => p().previous());
|
||||
expect(seek).toHaveBeenCalledWith(0);
|
||||
expect(p().queueIndex).toBe(1);
|
||||
expect(p().currentTime).toBe(0);
|
||||
});
|
||||
|
||||
test("previous on the first track restarts it", () => {
|
||||
const { p } = setup();
|
||||
const seek = vi.fn();
|
||||
act(() => p().playQueue([embed(1), embed(2)]));
|
||||
p().seekRef.current = seek;
|
||||
act(() => p().previous());
|
||||
expect(seek).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
test("picking the playing row restarts it, and resumes it if paused", () => {
|
||||
const { p } = setup();
|
||||
const seek = vi.fn();
|
||||
const toggle = vi.fn();
|
||||
act(() => p().playQueue([embed(1), embed(2)]));
|
||||
p().seekRef.current = seek;
|
||||
p().toggleRef.current = toggle;
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().playAt(0));
|
||||
expect(seek).toHaveBeenCalledWith(0);
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
act(() => p().onPlayStateChange(false));
|
||||
act(() => p().playAt(0));
|
||||
expect(toggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("the end of a track advances; the end of the queue stops", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([embed(1), embed(2)]));
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().onEnded());
|
||||
expect(p().queueIndex).toBe(1);
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().onEnded());
|
||||
expect(p().queueIndex).toBe(1);
|
||||
expect(p().playing).toBe(false);
|
||||
});
|
||||
|
||||
test("stop empties the player", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().play(embed(1)));
|
||||
act(() => p().stop());
|
||||
expect(p().current).toBeNull();
|
||||
expect(stored()).toBeNull();
|
||||
});
|
||||
|
||||
test("stale time updates from the old media are ignored until the new one plays", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().play(embed(1)));
|
||||
act(() => p().onTimeUpdate(42, 100));
|
||||
expect(p().currentTime).toBe(0);
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().onTimeUpdate(5, 100));
|
||||
expect(p().currentTime).toBe(5);
|
||||
expect(p().duration).toBe(100);
|
||||
});
|
||||
|
||||
test("seekTo and togglePlay reach the registered media", () => {
|
||||
const { p } = setup();
|
||||
const seek = vi.fn();
|
||||
const toggle = vi.fn();
|
||||
p().seekRef.current = seek;
|
||||
p().toggleRef.current = toggle;
|
||||
act(() => p().seekTo(12));
|
||||
act(() => p().togglePlay());
|
||||
expect(seek).toHaveBeenCalledWith(12);
|
||||
expect(toggle).toHaveBeenCalled();
|
||||
expect(p().currentTime).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence", () => {
|
||||
test("the queue is saved with its owner, and the time on pagehide", () => {
|
||||
const session = makeSession({ id: "me" });
|
||||
const { p } = setup(session);
|
||||
act(() => p().playQueue([file(1), file(2)], 1));
|
||||
expect(stored()).toMatchObject({ v: 2, index: 1, owner: "me", time: 0 });
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().onTimeUpdate(33, 100));
|
||||
globalThis.dispatchEvent(new Event("pagehide"));
|
||||
expect(stored().time).toBe(33);
|
||||
});
|
||||
|
||||
test("a saved session comes back paused at its offset", () => {
|
||||
localStorage.setItem(
|
||||
"player",
|
||||
JSON.stringify({ v: 2, queue: [embed(1), embed(2)], index: 1, time: 17, owner: null }),
|
||||
);
|
||||
const { p } = setup();
|
||||
expect(p().queueIndex).toBe(1);
|
||||
expect(p().startTime).toBe(17);
|
||||
expect(p().autoplay).toBe(false);
|
||||
});
|
||||
|
||||
test("a v1 single-item session is still read", () => {
|
||||
localStorage.setItem("player", JSON.stringify({ item: embed(3), time: 4 }));
|
||||
const { p } = setup();
|
||||
expect(p().current).toEqual(embed(3));
|
||||
expect(p().startTime).toBe(4);
|
||||
});
|
||||
|
||||
test("another account's queue isn't restored", () => {
|
||||
localStorage.setItem(
|
||||
"player",
|
||||
JSON.stringify({ v: 2, queue: [file(1)], index: 0, time: 0, owner: "someone-else" }),
|
||||
);
|
||||
const { p } = setup(makeSession({ id: "me" }));
|
||||
expect(p().current).toBeNull();
|
||||
});
|
||||
|
||||
test("an ownerless session holding file URLs isn't restored; one of embeds is", () => {
|
||||
localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 }));
|
||||
expect(setup().p().current).toBeNull();
|
||||
localStorage.setItem("player", JSON.stringify({ queue: [embed(1)], index: 0, time: 0 }));
|
||||
expect(setup().p().current).toEqual(embed(1));
|
||||
});
|
||||
|
||||
test("junk in storage is ignored", () => {
|
||||
localStorage.setItem("player", "{nope");
|
||||
expect(setup().p().current).toBeNull();
|
||||
localStorage.setItem(
|
||||
"player",
|
||||
JSON.stringify({ v: 2, queue: [{ kind: "weird" }], index: 0, owner: null }),
|
||||
);
|
||||
expect(setup().p().current).toBeNull();
|
||||
});
|
||||
|
||||
test("signing out stops a signed-in listener's queue", () => {
|
||||
const { p } = setup(makeSession({ id: "me" }));
|
||||
act(() => p().play(file(1)));
|
||||
act(() => setSession(null));
|
||||
expect(p().current).toBeNull();
|
||||
});
|
||||
|
||||
test("signing in keeps a guest's queue", () => {
|
||||
const { p } = setup(null);
|
||||
act(() => p().play(embed(1)));
|
||||
act(() => setSession(makeSession({ id: "me" })));
|
||||
expect(p().current).toEqual(embed(1));
|
||||
expect(stored().owner).toBe("me");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiring streams", () => {
|
||||
test("a stream error re-resolves and resumes on the same track", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1, 2]));
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([stream(0), stream(1), stream(2)], 1));
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().onTimeUpdate(40, 200));
|
||||
act(() => p().onError());
|
||||
expect(p().resolving).toBe(true);
|
||||
await waitFor(() => expect(p().resolving).toBe(false));
|
||||
expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" });
|
||||
expect(p().startTime).toBe(40);
|
||||
expect(p().autoplay).toBe(true);
|
||||
});
|
||||
|
||||
test("a track gone from the release restarts from the first one", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 2]));
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([stream(0), stream(1)], 1));
|
||||
act(() => p().onPlayStateChange(true));
|
||||
act(() => p().onTimeUpdate(40, 200));
|
||||
act(() => p().onError());
|
||||
await waitFor(() => expect(p().resolving).toBe(false));
|
||||
expect(p().queueIndex).toBe(0);
|
||||
expect(p().startTime).toBe(0);
|
||||
});
|
||||
|
||||
test("a resolve that fails falls back to the embed", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => fail("down", 502));
|
||||
const { p } = setup();
|
||||
act(() => p().play(stream(0)));
|
||||
act(() => p().onError());
|
||||
await waitFor(() => expect(p().current?.kind).toBe("embed"));
|
||||
expect(p().current).toMatchObject({
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
|
||||
title: "Track 0",
|
||||
});
|
||||
});
|
||||
|
||||
test("no embed to fall back to: the player stops", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => fail("down", 502));
|
||||
const { p } = setup();
|
||||
act(() => p().play(stream(0, { embedUrl: undefined })));
|
||||
act(() => p().onError());
|
||||
await waitFor(() => expect(p().current).toBeNull());
|
||||
});
|
||||
|
||||
test("a track that dies again right after a re-resolve is skipped", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([stream(0), stream(1)], 0));
|
||||
act(() => p().onError());
|
||||
await waitFor(() => expect(p().resolving).toBe(false));
|
||||
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
|
||||
act(() => p().onError());
|
||||
expect(p().queueIndex).toBe(1);
|
||||
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a resolve that lands after the user moved on is dropped", async () => {
|
||||
const pending = deferred();
|
||||
route("GET /api/bandcamp/tracks", () => pending.promise);
|
||||
const { p } = setup();
|
||||
act(() => p().play(stream(0)));
|
||||
act(() => p().onError());
|
||||
act(() => p().play(embed(9)));
|
||||
await act(async () =>
|
||||
pending.resolve(
|
||||
new Response(JSON.stringify({ success: true, data: albumResponse([0]) })),
|
||||
)
|
||||
);
|
||||
expect(p().current).toEqual(embed(9));
|
||||
expect(p().resolving).toBe(false);
|
||||
});
|
||||
|
||||
test("errors on non-stream items are ignored", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().play(embed(1)));
|
||||
act(() => p().onError());
|
||||
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a stale restored stream is refreshed up front, without autoplay", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(10 * STREAM_STALE_MS);
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
|
||||
localStorage.setItem(
|
||||
"player",
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
queue: [stream(0, { resolvedAt: 0 }), stream(1, { resolvedAt: 0 })],
|
||||
index: 1,
|
||||
time: 25,
|
||||
owner: null,
|
||||
}),
|
||||
);
|
||||
const { p } = setup();
|
||||
await waitFor(() => expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" }));
|
||||
expect(p().autoplay).toBe(false);
|
||||
expect(p().startTime).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setSession = () => {};
|
||||
});
|
||||
648
src/test/contexts/WSProvider.test.tsx
Normal file
648
src/test/contexts/WSProvider.test.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useContext } from "react";
|
||||
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
|
||||
import { WSProvider } from "../../contexts/WSProvider.tsx";
|
||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
import { useWS } from "../../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { defaultRoutes } from "../render.tsx";
|
||||
import {
|
||||
makeChatMessage,
|
||||
makeComment,
|
||||
makeDump,
|
||||
makeNotification,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeUser,
|
||||
storeSession,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
|
||||
const me = makeSession({ id: "user-me", username: "me" });
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function setup({ signedIn = true } = {}) {
|
||||
if (signedIn) storeSession(me);
|
||||
defaultRoutes({ session: signedIn ? me : null });
|
||||
const hook = renderHook(
|
||||
() => ({
|
||||
ws: useWS(),
|
||||
auth: useAuth(),
|
||||
avatars: useContext(AvatarOverrideContext),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
const socket = FakeWebSocket.last;
|
||||
return { hook, socket, ws: () => hook.result.current.ws };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("connection", () => {
|
||||
test("connects with the session token, and without one for guests", () => {
|
||||
const { socket } = setup();
|
||||
expect(socket.url).toBe(
|
||||
`ws://localhost:8000/ws?token=${encodeURIComponent(me.token)}`,
|
||||
);
|
||||
FakeWebSocket.reset();
|
||||
localStorage.clear();
|
||||
const guest = setup({ signedIn: false });
|
||||
expect(guest.socket.url).toBe("ws://localhost:8000/ws");
|
||||
});
|
||||
|
||||
test("connecting → connected on open", () => {
|
||||
const { socket, ws } = setup();
|
||||
expect(ws().wsStatus).toBe("connecting");
|
||||
socket.open();
|
||||
expect(ws().wsStatus).toBe("connected");
|
||||
expect(ws().wsErrorMessage).toBeNull();
|
||||
});
|
||||
|
||||
test("a socket stuck connecting times out with a message", () => {
|
||||
const { ws } = setup();
|
||||
act(() => vi.advanceTimersByTime(2_500));
|
||||
expect(ws().wsStatus).toBe("disconnected");
|
||||
expect(ws().wsErrorMessage).toMatch(/Can't connect/);
|
||||
});
|
||||
|
||||
test("reconnects with exponential backoff after a drop", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.drop();
|
||||
expect(ws().wsStatus).toBe("disconnected");
|
||||
expect(ws().wsErrorMessage).toMatch(/temporarily disconnected/);
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
FakeWebSocket.last.drop();
|
||||
act(() => vi.advanceTimersByTime(999));
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(FakeWebSocket.instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("answers pings", () => {
|
||||
const { socket } = setup();
|
||||
socket.open();
|
||||
socket.receive({ type: "ping" });
|
||||
expect(socket.messages).toEqual([{ type: "pong" }]);
|
||||
});
|
||||
|
||||
test("ignores garbage frames", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.open();
|
||||
socket.receiveRaw("not json");
|
||||
socket.receiveRaw(JSON.stringify({ nope: 1 }));
|
||||
socket.receiveRaw("null");
|
||||
expect(ws().wsStatus).toBe("connected");
|
||||
});
|
||||
|
||||
test("welcome loads presence, votes, likes and unread counts", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({
|
||||
users: [{ userId: "u1", username: "a", hasAvatar: false }],
|
||||
myVotes: ["d1"],
|
||||
myCommentLikes: ["c1"],
|
||||
unreadNotificationCount: 4,
|
||||
unreadChatCount: 2,
|
||||
chatReadAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(ws().onlineUsers).toHaveLength(1);
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
expect(ws().myLikes.has("c1")).toBe(true);
|
||||
expect(ws().unreadNotificationCount).toBe(4);
|
||||
expect(ws().unreadChatCount).toBe(2);
|
||||
expect(ws().chatReadAt).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
||||
expect(document.title).toMatch(/^\(6\)/);
|
||||
});
|
||||
|
||||
test("a reconnect bumps the epoch; the first connect doesn't", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
expect(ws().connectionEpoch).toBe(0);
|
||||
socket.drop();
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(ws().connectionEpoch).toBe(1);
|
||||
});
|
||||
|
||||
test("force_logout signs out", () => {
|
||||
const { socket, hook } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "force_logout" });
|
||||
expect(hook.result.current.auth.user).toBeNull();
|
||||
});
|
||||
|
||||
test("signing out resets live state and reconnects as a guest", () => {
|
||||
const { socket, hook, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 });
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "x" })) });
|
||||
act(() => hook.result.current.auth.logout());
|
||||
expect(ws().myVotes.size).toBe(0);
|
||||
expect(ws().recentDumps).toEqual([]);
|
||||
expect(ws().unreadNotificationCount).toBe(0);
|
||||
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
expect(FakeWebSocket.last.url).toBe("ws://localhost:8000/ws");
|
||||
});
|
||||
|
||||
test("unmounting closes the socket for good", () => {
|
||||
const { socket, hook } = setup();
|
||||
socket.welcome();
|
||||
hook.unmount();
|
||||
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("votes", () => {
|
||||
test("cast is optimistic, sent, and confirmed by the ack", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 4));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
expect(ws().voteCounts.d1).toBe(5);
|
||||
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
|
||||
socket.receive({ type: "vote_ack", dumpId: "d1", action: "cast", voteCount: 7 });
|
||||
expect(ws().voteCounts.d1).toBe(7);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
});
|
||||
|
||||
test("an unconfirmed vote rolls back after the ack timeout", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 2));
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(false);
|
||||
expect(ws().voteCounts.d1).toBe(2);
|
||||
});
|
||||
|
||||
test("a server error naming the dump rolls back at once", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"] });
|
||||
act(() => ws().removeVote("d1", 3));
|
||||
expect(ws().voteCounts.d1).toBe(2);
|
||||
socket.receive({ type: "error", message: "nope", dumpId: "d1", action: "remove" });
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
expect(ws().voteCounts.d1).toBe(3);
|
||||
});
|
||||
|
||||
test("casting twice or removing an absent vote does nothing", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"] });
|
||||
act(() => ws().castVote("d1", 1));
|
||||
act(() => ws().removeVote("d2", 1));
|
||||
expect(socket.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("remove never goes below zero", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"] });
|
||||
act(() => ws().removeVote("d1", 0));
|
||||
expect(ws().voteCounts.d1).toBe(0);
|
||||
});
|
||||
|
||||
test("offline, the vote is shown then rolled back", () => {
|
||||
const { ws } = setup();
|
||||
act(() => ws().castVote("d1", 0));
|
||||
expect(ws().voteCounts.d1).toBe(1);
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(ws().voteCounts.d1).toBe(0);
|
||||
});
|
||||
|
||||
test("votes_update from another tab of mine syncs myVotes", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "cast" });
|
||||
expect(ws().myVotes.has("d9")).toBe(true);
|
||||
expect(ws().lastVoteEvent).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast" });
|
||||
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 2, voterId: "other", action: "cast" });
|
||||
expect(ws().voteCounts.d9).toBe(2);
|
||||
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "remove" });
|
||||
expect(ws().myVotes.has("d9")).toBe(false);
|
||||
});
|
||||
|
||||
test("a welcome supersedes pending rollbacks", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 0));
|
||||
socket.receive({
|
||||
type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [],
|
||||
unreadNotificationCount: 0, unreadChatCount: 0,
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment likes", () => {
|
||||
test("like, ack, unlike, error rollback", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castCommentLike("c1", 0));
|
||||
expect(ws().likeCounts.c1).toBe(1);
|
||||
expect(socket.messages).toContainEqual({ type: "comment_like_cast", commentId: "c1" });
|
||||
socket.receive({ type: "comment_like_ack", commentId: "c1", action: "cast", likeCount: 1 });
|
||||
act(() => ws().removeCommentLike("c1"));
|
||||
expect(ws().likeCounts.c1).toBe(0);
|
||||
expect(socket.messages).toContainEqual({ type: "comment_like_remove", commentId: "c1" });
|
||||
socket.receive({ type: "error", commentId: "c1" });
|
||||
expect(ws().myLikes.has("c1")).toBe(true);
|
||||
expect(ws().likeCounts.c1).toBe(1);
|
||||
});
|
||||
|
||||
test("times out like votes do", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castCommentLike("c1", 5));
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(ws().likeCounts.c1).toBe(5);
|
||||
expect(ws().myLikes.has("c1")).toBe(false);
|
||||
});
|
||||
|
||||
test("comment_likes_update from my other tab", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "comment_likes_update", commentId: "c2", likeCount: 3, likerId: "user-me", action: "cast" });
|
||||
expect(ws().myLikes.has("c2")).toBe(true);
|
||||
expect(ws().likeCounts.c2).toBe(3);
|
||||
expect(ws().lastLikeEvent?.commentId).toBe("c2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dumps, playlists, comments, users", () => {
|
||||
test("created dumps go on top, deduplicated, and undo a deletion", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_deleted", dumpId: "a" });
|
||||
expect(ws().deletedDumpIds.has("a")).toBe(true);
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) });
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "again" })) });
|
||||
expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(ws().recentDumps[0].title).toBe("again");
|
||||
expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(ws().deletedDumpIds.has("a")).toBe(false);
|
||||
});
|
||||
|
||||
test("the live buffer is capped at 100", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
for (let i = 0; i < 105; i++) {
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: `d${i}` })) });
|
||||
}
|
||||
expect(ws().recentDumps).toHaveLength(100);
|
||||
expect(ws().recentDumps[0].id).toBe("d104");
|
||||
});
|
||||
|
||||
test("an update refreshes a live entry but doesn't make it an arrival", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "one" })) });
|
||||
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "a", title: "two" })) });
|
||||
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "old" })) });
|
||||
expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([["a", "two"]]);
|
||||
expect(ws().lastDumpEvent?.id).toBe("old");
|
||||
});
|
||||
|
||||
test("deletion removes from the live buffer", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
|
||||
socket.receive({ type: "dump_deleted", dumpId: "a" });
|
||||
expect(ws().recentDumps).toEqual([]);
|
||||
});
|
||||
|
||||
test("injectDump adds my own new dump once", () => {
|
||||
const { ws } = setup();
|
||||
const d = makeDump({ id: "mine" });
|
||||
act(() => ws().injectDump(d));
|
||||
act(() => ws().injectDump(d));
|
||||
expect(ws().recentDumps).toEqual([d]);
|
||||
});
|
||||
|
||||
test("playlist events", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const p = makePlaylist({ id: "p" });
|
||||
socket.receive({ type: "playlist_created", playlist: wire(p) });
|
||||
expect(ws().lastPlaylistEvent).toMatchObject({ type: "created", playlistId: "p" });
|
||||
socket.receive({ type: "playlist_updated", playlist: wire(p) });
|
||||
expect(ws().lastPlaylistEvent?.type).toBe("updated");
|
||||
socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"] });
|
||||
expect(ws().lastPlaylistEvent).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"] });
|
||||
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
|
||||
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
|
||||
expect(ws().lastPlaylistEvent).toEqual({ type: "deleted", playlistId: "p", userId: "u" });
|
||||
});
|
||||
|
||||
test("comment events", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const c = makeComment({ id: "c", dumpId: "d" });
|
||||
socket.receive({ type: "comment_created", comment: wire(c) });
|
||||
expect(ws().lastCommentEvent).toMatchObject({ type: "created", dumpId: "d" });
|
||||
expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date);
|
||||
socket.receive({ type: "comment_updated", comment: wire(c) });
|
||||
expect(ws().lastCommentEvent?.type).toBe("updated");
|
||||
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
|
||||
expect(ws().lastCommentEvent).toEqual({ type: "deleted", dumpId: "d", commentId: "c" });
|
||||
});
|
||||
|
||||
test("user_updated overrides that user's avatar everywhere", () => {
|
||||
const { socket, hook } = setup();
|
||||
socket.welcome();
|
||||
const u = makeUser({ id: "u1", avatarMime: "image/png", updatedAt: new Date(5000) });
|
||||
socket.receive({ type: "user_updated", user: wire(u) });
|
||||
expect(hook.result.current.avatars.u1).toEqual({ hasAvatar: true, version: 5000 });
|
||||
expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1");
|
||||
});
|
||||
|
||||
test("presence updates", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "presence_update", users: [{ userId: "x", username: "x", hasAvatar: false }] });
|
||||
expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifications", () => {
|
||||
test("a new one bumps the count and is remembered", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ unreadNotificationCount: 1 });
|
||||
socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })) });
|
||||
expect(ws().unreadNotificationCount).toBe(2);
|
||||
expect(ws().lastNotification?.id).toBe("n");
|
||||
act(() => ws().clearUnreadNotifications());
|
||||
expect(ws().unreadNotificationCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat", () => {
|
||||
test("messages are buffered once, and count as unread unless mine", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const m = makeChatMessage({ id: "m1", userId: "other" });
|
||||
socket.receive({ type: "chat_message", message: wire(m) });
|
||||
socket.receive({ type: "chat_message_updated", message: wire(m) });
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: "m2", userId: "user-me" })) });
|
||||
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]);
|
||||
expect(ws().unreadChatCount).toBe(1);
|
||||
});
|
||||
|
||||
test("the buffer is capped at 200", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
for (let i = 0; i < 205; i++) {
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })) });
|
||||
}
|
||||
expect(ws().chatMessages).toHaveLength(200);
|
||||
expect(ws().chatMessages[0].id).toBe("m5");
|
||||
});
|
||||
|
||||
test("with the chat open and visible, arrivals aren't unread", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatOpen(true));
|
||||
expect(socket.messages).toContainEqual({ type: "chat_focus", open: true });
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
expect(ws().unreadChatCount).toBe(0);
|
||||
});
|
||||
|
||||
test("an open chat in a hidden tab still counts unread", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatOpen(true));
|
||||
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(socket.messages.at(-1)).toEqual({ type: "chat_focus", open: false });
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
expect(ws().unreadChatCount).toBe(1);
|
||||
});
|
||||
|
||||
test("sending shows a pending message, retired by its echo", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" }));
|
||||
const [pending] = ws().pendingChatMessages;
|
||||
expect(pending).toMatchObject({ body: "hello", status: "sending" });
|
||||
expect(socket.messages).toContainEqual({
|
||||
type: "chat_send",
|
||||
body: "hello",
|
||||
nonce: pending.nonce,
|
||||
replyToId: "r",
|
||||
});
|
||||
socket.receive({
|
||||
type: "chat_message",
|
||||
message: wire(makeChatMessage({ userId: "user-me", body: "hello" })),
|
||||
nonce: pending.nonce,
|
||||
});
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
act(() => vi.advanceTimersByTime(20_000));
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("blank messages aren't sent", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage(" "));
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
expect(socket.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("offline sends fail at once", () => {
|
||||
const { ws } = setup();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" });
|
||||
expect(ws().pendingChatMessages[0].error).toMatch(/offline/);
|
||||
});
|
||||
|
||||
test("no echo in time marks the send failed; retry uses a fresh nonce", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
const first = ws().pendingChatMessages[0].nonce;
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" });
|
||||
act(() => ws().retryChatMessage(first));
|
||||
const retry = ws().pendingChatMessages[0];
|
||||
expect(retry.status).toBe("sending");
|
||||
expect(retry.nonce).not.toBe(first);
|
||||
expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(2);
|
||||
// The first attempt's late echo doesn't retire the retry.
|
||||
socket.receive({
|
||||
type: "chat_message",
|
||||
message: wire(makeChatMessage({ userId: "user-me", body: "hi" })),
|
||||
nonce: first,
|
||||
});
|
||||
expect(ws().pendingChatMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a server rejection fails the pending send; discard drops it", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
const { nonce } = ws().pendingChatMessages[0];
|
||||
socket.receive({ type: "error", scope: "chat", nonce, message: "Too long" });
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed", error: "Too long" });
|
||||
act(() => ws().discardChatMessage(nonce));
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("a chat error without a nonce is shown until dismissed", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "error", scope: "chat" });
|
||||
expect(ws().chatError?.message).toBe("Something went wrong");
|
||||
socket.receive({ type: "error", scope: "chat", message: "Not yours" });
|
||||
expect(ws().chatError?.message).toBe("Not yours");
|
||||
act(() => ws().dismissChatError());
|
||||
expect(ws().chatError).toBeNull();
|
||||
});
|
||||
|
||||
test("a dropped socket fails in-flight sends", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
socket.drop();
|
||||
expect(ws().pendingChatMessages[0].error).toMatch(/Connection lost/);
|
||||
});
|
||||
|
||||
test("edits and deletes", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().editChatMessage("m1", " new "));
|
||||
act(() => ws().editChatMessage("m1", " "));
|
||||
act(() => ws().deleteChatMessage("m1"));
|
||||
expect(socket.messages).toEqual([
|
||||
{ type: "chat_edit", id: "m1", body: "new" },
|
||||
{ type: "chat_delete", id: "m1" },
|
||||
]);
|
||||
const m = makeChatMessage({ id: "m1", body: "old" });
|
||||
socket.receive({ type: "chat_message", message: wire(m) });
|
||||
socket.receive({ type: "chat_message_updated", message: wire({ ...m, body: "new" }) });
|
||||
expect(ws().chatMessages[0].body).toBe("new");
|
||||
socket.receive({ type: "chat_message_updated", message: wire(makeChatMessage({ id: "m2" })) });
|
||||
expect(ws().chatMessages).toHaveLength(2);
|
||||
socket.receive({ type: "chat_message_deleted", id: "m1" });
|
||||
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]);
|
||||
expect(ws().deletedChatIds.has("m1")).toBe(true);
|
||||
});
|
||||
|
||||
test("typing: sent and received", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatTyping(true));
|
||||
expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]);
|
||||
socket.receive({ type: "chat_typing_update", users: [{ userId: "x", username: "x", hasAvatar: false }] });
|
||||
expect(ws().typingUsers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the read marker clears the badge at once and is throttled to the server", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ unreadChatCount: 3 });
|
||||
act(() => ws().markChatRead(new Date(1_000_000)));
|
||||
expect(ws().unreadChatCount).toBe(0);
|
||||
act(() => vi.advanceTimersByTime(0));
|
||||
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1);
|
||||
act(() => ws().markChatRead(new Date(2_000_000)));
|
||||
act(() => ws().markChatRead(new Date(3_000_000)));
|
||||
act(() => vi.advanceTimersByTime(999));
|
||||
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
const reads = socket.messages.filter((m) => m.type === "chat_read");
|
||||
expect(reads).toHaveLength(2);
|
||||
expect(reads[1].at).toBe(new Date(3_000_000).toISOString());
|
||||
// Older than what's been sent: ignored.
|
||||
act(() => ws().markChatRead(new Date(10)));
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a marker set offline goes out with the next welcome", () => {
|
||||
const { socket, ws } = setup();
|
||||
act(() => ws().markChatRead(new Date(5_000_000)));
|
||||
act(() => vi.advanceTimersByTime(2_000));
|
||||
socket.welcome();
|
||||
expect(socket.messages).toContainEqual({
|
||||
type: "chat_read",
|
||||
at: new Date(5_000_000).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
test("chat_read_update from another tab moves the marker", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ unreadChatCount: 5 });
|
||||
socket.receive({ type: "chat_read_update", readAt: "2026-05-01T00:00:00.000Z", unread: 1 });
|
||||
expect(ws().unreadChatCount).toBe(1);
|
||||
expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
test("reconcile replaces stale copies, drops deleted ones, retires landed sends", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const t0 = Date.now();
|
||||
const at = (s: number) => new Date(t0 + s * 1000);
|
||||
const early = makeChatMessage({ id: "early", createdAt: at(-100) });
|
||||
const edited = makeChatMessage({ id: "edited", body: "old", createdAt: at(-50) });
|
||||
const gone = makeChatMessage({ id: "gone", createdAt: at(-40) });
|
||||
for (const m of [early, edited, gone]) {
|
||||
socket.receive({ type: "chat_message", message: wire(m) });
|
||||
}
|
||||
act(() => ws().sendChatMessage("did it land?"));
|
||||
socket.drop();
|
||||
expect(ws().pendingChatMessages[0].status).toBe("failed");
|
||||
|
||||
act(() =>
|
||||
ws().reconcileChat(
|
||||
[
|
||||
{ ...edited, body: "new" },
|
||||
makeChatMessage({ id: "echo", userId: "user-me", body: "did it land?", createdAt: at(1) }),
|
||||
],
|
||||
at(-60),
|
||||
at(60),
|
||||
)
|
||||
);
|
||||
expect(ws().chatMessages.map((m) => [m.id, m.body])).toEqual([
|
||||
["early", early.body],
|
||||
["edited", "new"],
|
||||
]);
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("a reconnect with the chat closed drops the stale buffer", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
socket.drop();
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(ws().chatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("a reconnect with the chat open keeps it, and re-announces focus", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatOpen(true));
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
socket.drop();
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(ws().chatMessages).toHaveLength(1);
|
||||
expect(FakeWebSocket.last.messages).toContainEqual({ type: "chat_focus", open: true });
|
||||
});
|
||||
});
|
||||
313
src/test/contexts/providers.test.tsx
Normal file
313
src/test/contexts/providers.test.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
|
||||
import { FollowProvider } from "../../contexts/FollowProvider.tsx";
|
||||
import { CategoriesProvider } from "../../contexts/CategoriesProvider.tsx";
|
||||
import { ThemeProvider } from "../../contexts/ThemeProvider.tsx";
|
||||
import { ChatProvider } from "../../contexts/ChatProvider.tsx";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/useFollows.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
import { useTheme } from "../../hooks/useTheme.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
|
||||
import { calls, deferred, fail, ok, route } from "../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeSession,
|
||||
makeToken,
|
||||
storeSession,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
|
||||
const authWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
test("starts from the stored session", () => {
|
||||
const session = makeSession({ username: "ann" });
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user?.username).toBe("ann");
|
||||
});
|
||||
|
||||
test("a token the server rejects is dropped", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => new Response(null, { status: 401 }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.user).toBeNull());
|
||||
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
|
||||
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
|
||||
`Bearer ${session.token}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a role changed on the server is picked up", async () => {
|
||||
const session = makeSession({ role: "user" });
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire({ ...session.user, role: "moderator" }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
|
||||
"moderator",
|
||||
);
|
||||
});
|
||||
|
||||
test("offline doesn't sign anyone out", async () => {
|
||||
storeSession(makeSession());
|
||||
route("GET /api/users/me", () => new TypeError("offline"));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
expect(result.current.user).not.toBeNull();
|
||||
});
|
||||
|
||||
test("revalidates when the tab regains focus", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("a session without a media token fetches one", async () => {
|
||||
const session = { ...makeSession(), mediaToken: undefined };
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
|
||||
"m-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("follows sign-in and sign-out from other tabs", async () => {
|
||||
route("GET /api/users/me", () => ok(null));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user).toBeNull();
|
||||
|
||||
const session = makeSession({ username: "elsewhere" });
|
||||
storeSession(session);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new StorageEvent("storage", { key: AUTH_STORAGE_KEY }));
|
||||
});
|
||||
expect(result.current.user?.username).toBe("elsewhere");
|
||||
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new StorageEvent("storage", { key: "unrelated" }));
|
||||
});
|
||||
expect(result.current.user).not.toBeNull();
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new StorageEvent("storage", { key: null }));
|
||||
});
|
||||
expect(result.current.user).toBeNull();
|
||||
});
|
||||
|
||||
test("a 401 for an old token doesn't sign out a newer session", async () => {
|
||||
const first = makeSession();
|
||||
storeSession(first);
|
||||
const pending = deferred();
|
||||
route("GET /api/users/me", () => pending.promise, { once: true });
|
||||
route("GET /api/users/me", () => wire(first.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
const second = { ...first, token: makeToken() };
|
||||
act(() => result.current.login(second));
|
||||
await act(async () => pending.resolve(new Response(null, { status: 401 })));
|
||||
expect(result.current.token).toBe(second.token);
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
|
||||
second.token,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowProvider", () => {
|
||||
function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/follows/status", () => status);
|
||||
return renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<FollowProvider>{children}</FollowProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
test("loads what the user follows", async () => {
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.followedUserIds.has("u1")).toBe(true);
|
||||
expect(result.current.followedPlaylistIds.has("p1")).toBe(true);
|
||||
});
|
||||
|
||||
test("guests follow nobody and never ask", async () => {
|
||||
const { result } = renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<FollowProvider>{children}</FollowProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
expect(calls("GET /api/follows/status")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("follow and unfollow are optimistic and roll back on failure", async () => {
|
||||
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
|
||||
route("POST /api/follows/users/:id", () => ({}));
|
||||
await act(() => result.current.followUser("u2"));
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("DELETE /api/follows/users/:id", () => fail("no", 500));
|
||||
await act(() => result.current.unfollowUser("u2"));
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => ({}));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
route("DELETE /api/follows/playlists/:id", () => ({}));
|
||||
await act(() => result.current.unfollowPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
|
||||
"/api/follows/playlists/p2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CategoriesProvider", () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<CategoriesProvider>{children}</CategoriesProvider>
|
||||
);
|
||||
|
||||
test("loads and indexes categories", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music" });
|
||||
route("GET /api/categories", () => wire([music]));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.byId.get("c1")?.slug).toBe("music");
|
||||
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("refresh refetches", async () => {
|
||||
let list = [makeCategory({ id: "a" })];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.categories).toHaveLength(1));
|
||||
list = [...list, makeCategory({ id: "b" })];
|
||||
await act(() => result.current.refresh());
|
||||
expect(result.current.categories).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a failure still ends loading", async () => {
|
||||
route("GET /api/categories", () => fail("x", 500));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.categories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThemeProvider", () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
);
|
||||
const root = document.documentElement;
|
||||
|
||||
test("smooth and auto by default, with no attributes", () => {
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
expect(result.current.style).toBe("smooth");
|
||||
expect(result.current.colorScheme).toBe("auto");
|
||||
expect(root.hasAttribute("data-style")).toBe(false);
|
||||
expect(root.hasAttribute("data-color-scheme")).toBe(false);
|
||||
});
|
||||
|
||||
test("applies and persists a choice", () => {
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
act(() => result.current.setStyle("brutalist"));
|
||||
act(() => result.current.setColorScheme("dark"));
|
||||
expect(root.getAttribute("data-style")).toBe("brutalist");
|
||||
expect(root.getAttribute("data-color-scheme")).toBe("dark");
|
||||
expect(localStorage.getItem("style")).toBe("brutalist");
|
||||
expect(localStorage.getItem("color-scheme")).toBe("dark");
|
||||
act(() => result.current.setStyle("smooth"));
|
||||
act(() => result.current.setColorScheme("auto"));
|
||||
expect(root.hasAttribute("data-style")).toBe(false);
|
||||
expect(root.hasAttribute("data-color-scheme")).toBe(false);
|
||||
});
|
||||
|
||||
test("restores the stored theme, migrating the legacy name", () => {
|
||||
localStorage.setItem("style", "nyt");
|
||||
localStorage.setItem("color-scheme", "light");
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
expect(result.current.style).toBe("nyt");
|
||||
expect(result.current.colorScheme).toBe("light");
|
||||
localStorage.setItem("style", "default");
|
||||
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe("smooth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatProvider", () => {
|
||||
test("opens only for signed-in users", async () => {
|
||||
const guest = renderHook(useChat, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<MemoryRouter>
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
act(() => guest.result.current.openChat());
|
||||
expect(guest.result.current.isChatOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("?chat=open deep-links into it, and closing strips the param", async () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
function Probe() {
|
||||
chat = useChat();
|
||||
return null;
|
||||
}
|
||||
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
await screen.findByRole("dialog", { name: "Chat" });
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
expect(currentPath()).toBe("/somewhere?x=1");
|
||||
});
|
||||
|
||||
test("openChat / closeChat", () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
function Probe() {
|
||||
chat = useChat();
|
||||
return null;
|
||||
}
|
||||
renderApp(<Probe />, { session });
|
||||
act(() => chat.openChat());
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
11
src/test/emojibase.ts
Normal file
11
src/test/emojibase.ts
Normal 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
159
src/test/fetch.ts
Normal 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
144
src/test/fixtures.ts
Normal 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 };
|
||||
}
|
||||
321
src/test/hooks/data.test.tsx
Normal file
321
src/test/hooks/data.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
271
src/test/hooks/editor.test.tsx
Normal file
271
src/test/hooks/editor.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
452
src/test/hooks/misc.test.tsx
Normal file
452
src/test/hooks/misc.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
238
src/test/hooks/sync.test.tsx
Normal file
238
src/test/hooks/sync.test.tsx
Normal 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"]);
|
||||
});
|
||||
});
|
||||
261
src/test/hooks/useAuth.test.tsx
Normal file
261
src/test/hooks/useAuth.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
||||
338
src/test/pages/Dump.test.tsx
Normal file
338
src/test/pages/Dump.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
198
src/test/pages/Index.test.tsx
Normal file
198
src/test/pages/Index.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
128
src/test/pages/Notifications.test.tsx
Normal file
128
src/test/pages/Notifications.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
220
src/test/pages/PlaylistDetail.test.tsx
Normal file
220
src/test/pages/PlaylistDetail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
98
src/test/pages/Search.test.tsx
Normal file
98
src/test/pages/Search.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
77
src/test/pages/UserPlaylists.test.tsx
Normal file
77
src/test/pages/UserPlaylists.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
316
src/test/pages/UserPublicProfile.test.tsx
Normal file
316
src/test/pages/UserPublicProfile.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
117
src/test/pages/UserSubpages.test.tsx
Normal file
117
src/test/pages/UserSubpages.test.tsx
Normal 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")
|
||||
);
|
||||
});
|
||||
});
|
||||
237
src/test/pages/auth.test.tsx
Normal file
237
src/test/pages/auth.test.tsx
Normal 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
4
src/test/po.d.ts
vendored
Normal 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
213
src/test/render.tsx
Normal 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
95
src/test/setup.ts
Normal 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 };
|
||||
171
src/test/utils/journal.test.ts
Normal file
171
src/test/utils/journal.test.ts
Normal 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
166
src/test/utils/misc.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
113
src/test/utils/model.test.ts
Normal file
113
src/test/utils/model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
197
src/test/utils/remote.test.ts
Normal file
197
src/test/utils/remote.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
135
src/test/utils/sound.test.ts
Normal file
135
src/test/utils/sound.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
183
src/test/utils/storage.test.ts
Normal file
183
src/test/utils/storage.test.ts
Normal 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
157
src/test/utils/text.test.ts
Normal 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("", "Image")).toBe("cat");
|
||||
expect(markdownToPlainText("", "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 ).toBe(
|
||||
"pic alt",
|
||||
);
|
||||
expect(markdownToPlainText("pic ).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
101
src/test/ws.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user