/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>
714 lines
26 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|