import { describe, expect, test, vi } from "vitest"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx"; import type { PublicUser } from "../../model.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/authStorage.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 = "/users/ann", dumps = [makeDump({ id: "d1", title: "Her dump", userId: "ann" })], votes = [makeDump({ id: "v1", title: "She liked this" })], categories = [] as ReturnType[], } = {}, ) { route("GET /api/users/:u", () => wire(user)); route("GET /api/users/:u/dumps", () => page(dumps)); route("GET /api/users/:u/votes", () => page(votes)); route("GET /api/users/:u/playlists", () => page([makePlaylist({ title: "Her mix", userId: "ann" })])); const u = userEvent.setup(); renderApp(, { route: url, path: "/users/:username/*", session, categories, }); return { user: u, socket: FakeWebSocket.last }; } describe("UserPublicProfile", () => { test("header, description, and the dumps tab", async () => { open(); expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root"); expect(screen.getByText("dump").tagName).toBe("STRONG"); expect(screen.getByText("Her dump")).toBeInTheDocument(); expect(screen.getByText("She liked this")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Dumps (1)" })).toBeInTheDocument(); await waitFor(() => expect(document.title).toMatch(/^ann · /)); expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); }); test("an unknown user", async () => { route("GET /api/users/:u", () => fail("User not found", 404)); route("GET /api/users/:u/dumps", () => page([])); route("GET /api/users/:u/votes", () => page([])); route("GET /api/users/:u/playlists", () => page([])); renderApp(, { route: "/users/nobody", path: "/users/:username/*" }); 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 in the URL", async () => { const { user } = open(); await screen.findByRole("heading", { name: "ann" }); await user.click(screen.getByRole("button", { name: "Playlists" })); expect(currentPath()).toBe("/users/ann/~/playlists"); expect(screen.getByText("Her mix")).toBeInTheDocument(); }); test("followed: people and playlists, with more people on demand", async () => { route("GET /api/users/:u/followed-users", (req) => req.url.searchParams.get("page") === "1" ? page([makeUser({ username: "bob" })], true) : page([makeUser({ username: "cy" })])); route("GET /api/users/:u/followed-playlists", () => page([makePlaylist({ title: "Their mix" })])); const { user } = open({ url: "/users/ann/~/followed" }); expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute("href", "/users/bob"); expect(screen.getByText("Their mix")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Load more" })); expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Load more" })).not.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: "/users/ann/~/invitees" }); const bob = (await screen.findByRole("link", { name: /@bob/ })).closest("li")!; expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument(); }); test("visitors can follow; signed in, a settings tab 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: "Settings" })).not.toBeInTheDocument(); }); describe("your own profile", () => { test("edit the description; clearing it really clears it", async () => { route("PATCH /api/users/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/users/me")[0].body).toEqual({ description: "" }); }); test("edit the email", async () => { route("PATCH /api/users/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/users/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("POST /api/avatars/me", () => 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/avatars/ann?v=7000", ) ); }); test("a rejected avatar says why", async () => { route("POST /api/avatars/me", () => 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("settings: theme, colour scheme, default tab and sounds", async () => { const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); await user.click(await screen.findByRole("button", { name: "Brutalist" })); expect(document.documentElement.getAttribute("data-style")).toBe("brutalist"); await user.click(screen.getByRole("button", { name: "Dark" })); expect(document.documentElement.getAttribute("data-color-scheme")).toBe("dark"); await user.click(screen.getByRole("button", { name: "New" })); expect(localStorage.getItem("default-feed-tab")).toBe("new"); await user.click(screen.getByRole("button", { name: "Off" })); expect(localStorage.getItem("sounds")).toBe("off"); document.documentElement.removeAttribute("data-style"); document.documentElement.removeAttribute("data-color-scheme"); }); test("change password keeps you signed in with the fresh tokens", async () => { route("POST /api/users/me/change-password", () => ({ token: "fresh-token", mediaToken: "fresh-media" })); const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); await user.click(await screen.findByRole("button", { name: "Change password…" })); const dialog = screen.getByRole("dialog", { name: "Change password" }); await user.type(within(dialog).getByLabelText("Current password"), "oldpassword"); await user.type(within(dialog).getByLabelText("New password"), "newpassword"); await user.type(within(dialog).getByLabelText("Confirm new password"), "different"); expect(await within(dialog).findByText("Passwords do not match")).toBeInTheDocument(); await user.clear(within(dialog).getByLabelText("Confirm new password")); await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword"); await user.click(within(dialog).getByRole("button", { name: "Change password" })); expect(await within(dialog).findByText("Password changed successfully.")).toBeInTheDocument(); expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe("fresh-token"); expect(calls("POST /api/users/me/change-password")[0].body).toEqual({ currentPassword: "oldpassword", newPassword: "newpassword", }); }); test("a wrong current password is reported", async () => { route("POST /api/users/me/change-password", () => fail("Current password is incorrect", 400)); const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); await user.click(await screen.findByRole("button", { name: "Change password…" })); const dialog = screen.getByRole("dialog", { name: "Change password" }); await user.type(within(dialog).getByLabelText("Current password"), "wrong"); await user.type(within(dialog).getByLabelText("New password"), "newpassword"); await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword{Enter}"); expect(await within(dialog).findByRole("alert")).toHaveTextContent("Current password is incorrect"); }); test("log out", async () => { const { user } = open({ session: annSession }); await user.click(await screen.findByRole("button", { name: "Log out" })); expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); }); }); 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: "/users/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: "/users/ann/~/manage" }); await user.click(await screen.findByRole("button", { name: "Admin" })); expect(await screen.findByRole("alert")).toHaveTextContent("Cannot demote the last admin"); }); test("non-admins get no manage tab", async () => { open({ session: makeSession({ id: "mod", role: "moderator" }), url: "/users/ann/~/manage" }); await screen.findByRole("heading", { name: "ann" }); expect(screen.queryByRole("button", { name: "Manage" })).not.toBeInTheDocument(); expect(screen.getByText("Her dump")).toBeInTheDocument(); }); test("on your own profile, an admin manages categories", async () => { const music = makeCategory({ id: "c1", slug: "music", name: "Music" }); let list = [music]; route("GET /api/categories", () => wire(list)); route("POST /api/categories", (req) => { const c = makeCategory({ id: "c2", ...(req.body as object) }); list = [...list, c]; return wire(c); }); route("DELETE /api/categories/:id", () => { list = list.filter((c) => c.id !== "c1"); return null; }); const admin = makeSession({ id: "ann", username: "ann", role: "admin" }); const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } }); await user.click(await screen.findByRole("button", { name: "Add category" })); await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema"); await user.type(screen.getByRole("textbox", { name: "URL slug" }), "cinema"); await user.click(screen.getByRole("button", { name: "Save" })); expect(await screen.findByRole("button", { name: "Cinema" })).toBeInTheDocument(); expect(calls("POST /api/categories")[0].body).toEqual({ name: "Cinema", slug: "cinema" }); await user.click(screen.getByRole("button", { name: "Music" })); await user.click(screen.getByRole("button", { name: "Delete" })); await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete category" })); await waitFor(() => expect(screen.queryByRole("button", { name: "Music" })).not.toBeInTheDocument()); }); test("a refused category is reported, and Escape closes the editor", async () => { route("GET /api/categories", () => []); route("POST /api/categories", () => fail("Slug already taken", 409)); const admin = makeSession({ id: "ann", username: "ann", role: "admin" }); const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } }); await user.click(await screen.findByRole("button", { name: "Add category" })); await user.type(screen.getByRole("textbox", { name: "Name" }), "X"); await user.type(screen.getByRole("textbox", { name: "URL slug" }), "x{Enter}"); expect(await screen.findByRole("alert")).toHaveTextContent("Slug already taken"); await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}"); expect(screen.getByRole("button", { name: "Add category" })).toBeInTheDocument(); }); }); });