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 | 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[], } = {}, ) { 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(, { 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(, { 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(, { 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( ".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( ".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(); }); }); });