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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user