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>
317 lines
17 KiB
TypeScript
317 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|