Files
gerbeur/src/test/pages/UserPublicProfile.test.tsx
2026-10-08 22:22:15 +00:00

526 lines
19 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx";
import type { PublicUser } from "../../../shared/user.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/auth/storage.ts";
import { FakeIntersectionObserver } from "../setup.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 = "/@ann",
dumps = [makeDump({ id: "d1", title: "Her dump", userId: "ann" })],
votes = [makeDump({ id: "v1", title: "She liked this" })],
playlists = [makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })],
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/upvoted", () => page(votes));
route("GET /api/users/:u/playlists", () => page(playlists));
const u = userEvent.setup();
renderApp(<UserPublicProfile />, {
route: url,
path: "/:handle/*",
session,
categories,
});
return { user: u, socket: FakeWebSocket.last };
}
describe("UserPublicProfile", () => {
test("header, description, and the overview", async () => {
open();
expect(await screen.findByRole("heading", { name: "ann" }))
.toBeInTheDocument();
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute(
"href",
"/@root",
);
expect(screen.getByText("dump").tagName).toBe("STRONG");
expect(await screen.findByText("Her dump")).toBeInTheDocument();
expect(await screen.findByText("She liked this")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps (1)" }))
.toBeInTheDocument();
const [dumpsAll, votesAll] = screen.getAllByRole("link", {
name: "View all →",
});
expect(dumpsAll).toHaveAttribute("href", "/@ann/dumps");
expect(votesAll).toHaveAttribute("href", "/@ann/upvoted");
await waitFor(() => expect(document.title).toMatch(/^ann · /));
// The lists are read by the user's id, which a rename doesn't change.
expect(calls("GET /api/users/:u/dumps")[0].path).toBe(
"/api/users/ann/dumps",
);
expect(calls("GET /api/users/:u")[0].path).toBe("/api/users/@ann");
});
test("an unknown user", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
renderApp(<UserPublicProfile />, {
route: "/@nobody",
path: "/:handle/*",
});
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 plain segments, the overview the bare path", async () => {
const { user } = open();
await screen.findByRole("heading", { name: "ann" });
const tabs = document.querySelector(".profile-tabs") as HTMLElement;
await user.click(within(tabs).getByRole("button", { name: "Playlists" }));
expect(currentPath()).toBe("/@ann/playlists");
expect(await screen.findByText("Her mix")).toBeInTheDocument();
await user.click(within(tabs).getByRole("button", { name: "Overview" }));
expect(currentPath()).toBe("/@ann");
});
test("a tab or view the profile hasn't is not found", async () => {
for (const url of ["/@ann/nope", "/@ann/follow/users", "/@ann/dumps/x"]) {
const { unmount } = renderApp(<UserPublicProfile />, {
route: url,
path: "/:handle/*",
});
expect(await screen.findByText("This page does not exist."))
.toBeInTheDocument();
unmount();
}
});
test("a former name or another case: the URL takes the current one", async () => {
open({ url: "/@ANN_OLD/playlists?x=1" });
await screen.findByRole("heading", { name: "ann" });
await waitFor(() => expect(currentPath()).toBe("/@ann/playlists?x=1"));
expect(await screen.findByText("Her mix")).toBeInTheDocument();
// The URL's change names the user on screen, who isn't fetched again.
expect(calls("GET /api/users/:u")).toHaveLength(1);
});
test("the dumps tab lists them all; a visitor loses one gone private, and gets it back", async () => {
const list = ["x", "y", "z"].map((id) =>
makeDump({ id, title: `T-${id}`, userId: "ann" })
);
const { socket } = open({ url: "/@ann/dumps", dumps: list });
await screen.findByText("T-z");
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /New dump/ })).not
.toBeInTheDocument();
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"]);
});
test("the dumps tab loads more as its end scrolls into view", async () => {
open({ url: "/@ann/dumps" });
// After open's, so that it wins.
route(
"GET /api/users/:u/dumps",
(req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ title: "First", userId: "ann" })], true)
: page([makeDump({ title: "Second", userId: "ann" })]),
);
await screen.findByText("First");
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
expect(await screen.findByText("Second")).toBeInTheDocument();
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
});
test("the upvoted tab follows their votes", async () => {
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "v3", title: "Fresh like" })),
);
const { socket } = open({ url: "/@ann/upvoted" });
await screen.findByText("She liked this");
expect(screen.getByRole("heading", { name: "Upvoted" }))
.toBeInTheDocument();
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",
)
);
});
test("the playlists tab holds hers only, kept live", async () => {
const { socket } = open({ url: "/@ann/playlists" });
await screen.findByText("Her mix");
expect(calls("GET /api/users/:u/follow/playlists")).toHaveLength(0);
expect(screen.queryByRole("button", { name: "Delete playlist" })).not
.toBeInTheDocument();
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();
});
test("follow: users at the bare tab, playlists one segment further", async () => {
route(
"GET /api/users/:u/follow/users",
() => page([makeUser({ username: "bob" })]),
);
route(
"GET /api/users/:u/follow/playlists",
() => page([makePlaylist({ title: "Their mix" })]),
);
const { user } = open({ url: "/@ann/follow" });
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute(
"href",
"/@bob",
);
expect(calls("GET /api/users/:u/follow/playlists")).toHaveLength(0);
const views = document.querySelector(".followed-sub-nav") as HTMLElement;
await user.click(within(views).getByRole("button", { name: "Playlists" }));
expect(currentPath()).toBe("/@ann/follow/playlists");
expect(await screen.findByText("Their mix")).toBeInTheDocument();
await user.click(within(views).getByRole("button", { name: "Users" }));
expect(currentPath()).toBe("/@ann/follow");
});
test("follow: more users as the list's end scrolls into view", async () => {
route(
"GET /api/users/:u/follow/users",
(req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "bob" })], true)
: page([makeUser({ username: "cy" })]),
);
open({ url: "/@ann/follow" });
await screen.findByRole("link", { name: /@bob/ });
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
expect(await screen.findByRole("link", { name: /@cy/ }))
.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: "/@ann/invitees" });
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest(
"li",
)!;
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
});
test("visitors can follow; inviting 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: "+ Invite someone" })).not
.toBeInTheDocument();
});
describe("your own profile", () => {
test("edit the description; clearing it really clears it", async () => {
route(
"PATCH /api/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/me")[0].body).toEqual({ description: "" });
});
test("edit the email", async () => {
route(
"PATCH /api/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/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(
"PUT /api/me/avatar",
() =>
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/users/ann/avatar?v=7000",
)
);
});
test("a rejected avatar says why", async () => {
route("PUT /api/me/avatar", () => 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("an admin's own profile has no manage tab: that's for others'", async () => {
open({
session: { ...annSession, user: { ...annSession.user, role: "admin" } },
user: { ...ann, role: "admin" },
});
expect(await screen.findByRole("button", { name: "+ Invite someone" }))
.toBeInTheDocument();
expect(
within(document.querySelector(".profile-tabs") as HTMLElement)
.queryByRole("button", { name: "Manage" }),
).not.toBeInTheDocument();
});
});
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: "/@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: "/@ann/manage",
});
await user.click(await screen.findByRole("button", { name: "Admin" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Cannot demote the last admin",
);
});
test("an admin renames someone, and the URL follows", async () => {
route(
"PATCH /api/users/:id/username",
(req) =>
wire({
...ann,
username: (req.body as { username: string }).username,
}),
);
const { user } = open({
session: makeSession({ id: "admin", role: "admin" }),
url: "/@ann/manage",
});
const name = await screen.findByRole("textbox", { name: "Username" });
await user.clear(name);
await user.type(name, "annie{Enter}");
await waitFor(() => expect(currentPath()).toBe("/@annie/manage"));
expect(screen.getByRole("heading", { name: "annie" }))
.toBeInTheDocument();
expect(calls("PATCH /api/users/:id/username")[0]).toMatchObject({
path: "/api/users/ann/username",
body: { username: "annie" },
});
});
test("a refused rename says why, and a malformed one isn't sent", async () => {
route(
"PATCH /api/users/:id/username",
() => fail("Username already exists", 409),
);
const { user } = open({
session: makeSession({ id: "admin", role: "admin" }),
url: "/@ann/manage",
});
const name = await screen.findByRole("textbox", { name: "Username" });
await user.type(name, " x{Enter}");
expect(await screen.findByText(/Letters, numbers and underscores/))
.toBeInTheDocument();
expect(calls("PATCH /api/users/:id/username")).toHaveLength(0);
await user.clear(name);
await user.type(name, "annx{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Username already exists",
);
});
test("non-admins get no manage tab, nor its page", async () => {
open({
session: makeSession({ id: "mod", role: "moderator" }),
url: "/@ann/manage",
});
expect(await screen.findByText("This page does not exist."))
.toBeInTheDocument();
});
});
});