Files
gerbeur/src/test/pages/UserPublicProfile.test.tsx
khannurien 561cdf98b0 v4: playlists sort by their last dump, their creation or their size, and /playlists is a grid of covers — every list of playlists came newest first, so one fed every week sank under those made since and left empty. GET /api/playlists and /api/users/:key/playlists take ?sort=updated|new|dumps (PLAYLIST_SORTS, shared/playlist.ts), updated being the default: the last to take a dump first, or its creation while it holds none; a new title, cover or description doesn't bring it back up. A playlist carries that date as lastDumpAt, read with its dumpCount by isMemberVisibleTo's rule (VISIBLE_DUMP_FIGURES, the viewer's id as ?1), so the owner's private dumps date and count their playlists for them alone, in the lists and over the socket: playlist_created, playlist_updated and playlist_dumps_updated carry each socket's own. The /playlists page and a profile's playlists tab show the orders as tabs beside the category menu and keep the one picked in ?sort=, the default being the bare path; a tab takes the current history entry's place, and picking a category keeps the order. Live, usePlaylistListSync takes the list's order: a new playlist goes where it puts it, one that rises moves up to its place, as in the updated order when it takes a dump, and one that falls stays until the list is fetched again, its place being perhaps on a page not loaded. The lists in an order of their own, the followed playlists and the search's, pass none, and take no new playlist as before (noNewEntries goes). comparePlaylists is the order on the client, which a test holds to the API's.
/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …".

A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it.

A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:51:37 +00:00

714 lines
26 KiB
TypeScript

import { API_URL } from "../../config/api.ts";
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("a user from another instance shows their home, and edits nothing here", async () => {
const alice = makeUser({ id: "al", username: "alice@home.example" });
open({
user: alice,
url: "/@alice@home.example",
session: makeSession({ id: "al", username: "alice@home.example" }),
});
expect(await screen.findByRole("heading", { name: "alice@home.example" }))
.toBeInTheDocument();
// Through the API, which redirects to the home's pages.
expect(screen.getByRole("link", { name: "home.example" })).toHaveAttribute(
"href",
`${API_URL}/api/users/@alice%40home.example/home`,
);
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
expect(screen.queryByTitle("Edit email")).not.toBeInTheDocument();
expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
expect(calls("GET /api/users/:u")[0].path).toBe(
"/api/users/@alice%40home.example",
);
});
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 gains her new dumps live, and reads its pages again on a reconnect", async () => {
const old = makeDump({
id: "o",
title: "Older",
userId: "ann",
createdAt: new Date(Date.now() - 60_000),
});
const { socket } = open({ url: "/@ann/dumps", dumps: [old] });
await screen.findByText("Older");
socket.welcome();
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "n", title: "Fresh", userId: "ann" })),
});
// Someone else's goes to their own profile.
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "x", title: "Not hers", userId: "bob" })),
});
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title"))
.map((a) => a.textContent);
expect(titles()).toEqual(["Fresh", "Older"]);
// Deleted while the socket was down.
route("GET /api/users/:u/dumps", () => page([old]));
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await waitFor(() => expect(titles()).toEqual(["Older"]));
});
test("the dumps tab narrows to a category, in its URL, and drops a dump edited out of it; only the dump lists keep it", async () => {
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
const tagged = makeDump({
id: "m",
title: "Tagged",
userId: "ann",
categoryIds: ["c1"],
});
const { user, socket } = open({ url: "/@ann/dumps", categories: [music] });
route("GET /api/users/:u/dumps", (req) =>
page(
req.url.searchParams.get("category") === "music"
? [tagged]
: [tagged, makeDump({ id: "o", title: "Other", userId: "ann" })],
));
await screen.findByText("Other");
await user.click(screen.getByRole("button", { name: "All" }));
await user.click(screen.getByRole("option", { name: "Music" }));
expect(currentPath()).toBe("/@ann/dumps?category=music");
await waitFor(() =>
expect(screen.queryByText("Other")).not.toBeInTheDocument()
);
expect(screen.getByText("Tagged")).toBeInTheDocument();
socket.welcome();
socket.receive({
type: "dump_updated",
dump: wire({ ...tagged, categoryIds: [] }),
});
expect(screen.queryByText("Tagged")).not.toBeInTheDocument();
// Switching to the upvoted list keeps the category.
await user.click(screen.getByRole("button", { name: "Upvoted" }));
expect(currentPath()).toBe("/@ann/upvoted?category=music");
await waitFor(() =>
expect(calls("GET /api/users/:u/upvoted").at(-1)!.url.searchParams.get(
"category",
)).toBe("music")
);
// Any other tab drops it.
await user.click(screen.getByRole("button", { name: "Playlists" }));
expect(currentPath()).toBe("/@ann/playlists");
});
test("the playlists tab narrows to a playlist category, in its URL", async () => {
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
const mixes = makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
});
const filed = makePlaylist({
id: "p1",
title: "Filed mix",
userId: "ann",
categoryIds: ["pc1"],
});
const { user, socket } = open({
url: "/@ann/playlists",
categories: [music, mixes],
});
route("GET /api/users/:u/playlists", (req) =>
page(
req.url.searchParams.get("category") === "mixes"
? [filed]
: [filed, makePlaylist({ id: "p2", title: "Loose", userId: "ann" })],
));
await screen.findByText("Loose");
await user.click(screen.getByRole("button", { name: "All" }));
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
await user.click(screen.getByRole("option", { name: "Mixes" }));
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
await waitFor(() =>
expect(screen.queryByText("Loose")).not.toBeInTheDocument()
);
expect(screen.getByText("Filed mix")).toBeInTheDocument();
socket.welcome();
socket.receive({
type: "playlist_updated",
playlist: wire({ ...filed, categoryIds: [] }),
});
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
});
test("the playlists tab's order is in its URL, beside the category, and no other tab's", async () => {
const mixes = makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
});
const { user } = open({ url: "/@ann/playlists", categories: [mixes] });
route(
"GET /api/users/:u/playlists",
() => page([makePlaylist({ title: "Mix", userId: "ann" })]),
);
const asked = () =>
calls("GET /api/users/:u/playlists").at(-1)!.url.searchParams;
await screen.findByText("Mix");
expect(asked().get("sort")).toBeNull();
await user.click(screen.getByRole("button", { name: "New" }));
expect(currentPath()).toBe("/@ann/playlists?sort=new");
await waitFor(() => expect(asked().get("sort")).toBe("new"));
await user.click(screen.getByRole("button", { name: "All" }));
await user.click(screen.getByRole("option", { name: "Mixes" }));
expect(currentPath()).toBe("/@ann/playlists?sort=new&category=mixes");
await waitFor(() => expect(asked().get("category")).toBe("mixes"));
expect(asked().get("sort")).toBe("new");
// The default order is the bare tab's.
await user.click(screen.getByRole("button", { name: "Updated" }));
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
// Neither the order nor a playlist category means anything to the dumps.
await user.click(screen.getByRole("button", { name: "Most dumps" }));
expect(currentPath()).toBe("/@ann/playlists?category=mixes&sort=dumps");
await user.click(screen.getByRole("button", { name: "Dumps" }));
expect(currentPath()).toBe("/@ann/dumps");
});
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("a user from another instance is named there, not here", async () => {
open({
session: makeSession({ id: "admin", role: "admin" }),
user: { ...ann, username: "ann@home.example" },
url: "/@ann@home.example/manage",
});
expect(
await screen.findByText("ann@home.example", {
selector: ".profile-appearance-value",
}),
).toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "Username" })).toBeNull();
});
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();
});
});
});