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