526 lines
19 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|