import { describe, expect, test } from "vitest"; import { act, screen, waitFor, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { useLocation } from "react-router"; import { Index } from "../../pages/Index.tsx"; import { FEED_TABS, type FeedTab, type FollowView, } from "../../../shared/paths.ts"; import { calls, fail, route } from "../fetch.ts"; import { makeCategory, makeDump, makeSession, page, wire, } from "../fixtures.ts"; import { currentPath, renderApp } from "../render.tsx"; import { FakeWebSocket } from "../ws.ts"; import { FakeIntersectionObserver } from "../setup.ts"; const HOUR = 3_600_000; const music = makeCategory({ id: "c-music", slug: "music", name: "Music" }); /** Reads a feed's path as `App`'s routes do. */ function Scoped() { const { pathname } = useLocation(); const segments = pathname.split("/").filter(Boolean); const isTab = (FEED_TABS as readonly string[]).includes(segments[0] ?? ""); const categorySlug = isTab ? undefined : segments[0]; const [feedTab, followView] = segments.slice(isTab ? 0 : 1); return ( ); } function setup( url: string, { session = null as ReturnType | null } = {}, ) { const user = userEvent.setup(); renderApp(, { route: url, path: "*", session, categories: [music], }); const socket = FakeWebSocket.last; socket.welcome(); return { user, socket }; } const titles = () => Array.from(document.querySelectorAll(".dump-card-title, .journal-card-title")) .map((a) => a.textContent); describe("Index feeds", () => { const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR), }); const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000), }); test("New sorts by date, Hot by score", async () => { route("GET /api/dumps", () => page([older, newer])); const { user } = setup("/new"); await screen.findByText("Brand new"); expect(titles()).toEqual(["Brand new", "Old but loved"]); await user.click(screen.getAllByRole("button", { name: "Hot" })[0]); expect(currentPath()).toBe("/hot"); expect(titles()).toEqual(["Old but loved", "Brand new"]); expect(screen.getByText("You've reached the end.")).toBeInTheDocument(); // One fetch: switching tabs re-sorts what's loaded. expect(calls("GET /api/dumps")).toHaveLength(1); }); test("the bare path is the viewer's default tab, with no redirect", async () => { route("GET /api/dumps", () => page([older, newer])); localStorage.setItem("default-feed-tab", "hot"); const { user } = setup("/"); await screen.findByText("Brand new"); expect(currentPath()).toBe("/"); expect(titles()).toEqual(["Old but loved", "Brand new"]); await user.click(screen.getAllByRole("button", { name: "New" })[0]); expect(currentPath()).toBe("/new"); // The default tab is the bare path again. await user.click(screen.getAllByRole("button", { name: "Hot" })[0]); expect(currentPath()).toBe("/"); }); test('a default tab stored as v3\'s "followed" is "follow" now', async () => { route("GET /api/dumps", () => page([])); localStorage.setItem("default-feed-tab", "followed"); setup("/", { session: makeSession() }); expect( await screen.findByText("Follow some users to see their dumps here."), ).toBeInTheDocument(); expect(localStorage.getItem("default-feed-tab")).toBe("follow"); }); test("Journal lays the feed out as a mosaic", async () => { route("GET /api/dumps", () => page([older, newer])); setup("/journal"); await screen.findByText("Brand new"); expect(document.querySelector(".journal-grid")).not.toBeNull(); }); test("an empty feed and a failed one", async () => { route("GET /api/dumps", () => page([])); const first = setup("/new"); await screen.findByText("No dumps yet. Be the first!"); first.socket.drop(); }); test("a failed load shows an error and disables posting", async () => { route("GET /api/dumps", () => fail("boom", 500)); setup("/new", { session: makeSession() }); expect(await screen.findByRole("alert")).toHaveTextContent( "Failed to load", ); expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled(); }); test("sends the token so your own private dumps come back", async () => { const session = makeSession(); route("GET /api/dumps", () => page([])); setup("/new", { session }); await screen.findByText("No dumps yet. Be the first!"); expect(calls("GET /api/dumps")[0].headers.get("Authorization")).toBe( `Bearer ${session.token}`, ); }); test("scrolling to the end loads the next page", async () => { route( "GET /api/dumps", (req) => req.url.searchParams.get("page") === "1" ? page([newer], true) : page([older], false), ); setup("/new"); await screen.findByText("Brand new"); const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0 )!; act(() => observer.report(true)); await screen.findByText("Old but loved"); expect(calls("GET /api/dumps")[1].url.searchParams.get("page")).toBe("2"); }); test("live: a new dump goes on top; a deleted one goes away", async () => { route("GET /api/dumps", () => page([older])); const { socket } = setup("/new"); await screen.findByText("Old but loved"); socket.receive({ type: "dump_created", dump: wire( makeDump({ id: "live", title: "Just now", createdAt: new Date() }), ), }); expect(titles()[0]).toBe("Just now"); socket.receive({ type: "dump_deleted", dumpId: "old" }); expect(screen.queryByText("Old but loved")).not.toBeInTheDocument(); }); test("live votes update the count", async () => { route("GET /api/dumps", () => page([older])); const { socket } = setup("/new"); await screen.findByText("Old but loved"); socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast", }); expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent( "31", ); }); test("guests can't vote", async () => { route("GET /api/dumps", () => page([older])); setup("/new"); await screen.findByText("Old but loved"); expect(screen.getByRole("button", { name: "Upvote" })).toHaveAttribute( "aria-disabled", "true", ); }); test("a reconnect backfills what was missed", async () => { route("GET /api/dumps", () => page([older]), { once: true }); route("GET /api/dumps", () => page([newer, older])); const { socket } = setup("/new"); await screen.findByText("Old but loved"); socket.drop(); await act(() => new Promise((r) => setTimeout(r, 600))); FakeWebSocket.last.welcome(); await screen.findByText("Brand new"); }); test("a category scopes the feed, and live arrivals to it", async () => { route( "GET /api/dumps", (req) => page( req.url.searchParams.get("category") === "music" ? [makeDump({ title: "A song", categoryIds: ["c-music"] })] : [], ), ); const { socket } = setup("/music/new"); await screen.findByText("A song"); expect(calls("GET /api/dumps")[0].url.searchParams.get("category")).toBe( "music", ); await waitFor(() => expect(document.title).toMatch(/^Music · /)); socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })), }); socket.receive({ type: "dump_created", dump: wire( makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date(), }), ), }); expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument(); expect(screen.getByText("Another song")).toBeInTheDocument(); }); test("a dump edited out of the category leaves its feed", async () => { const song = makeDump({ id: "s", title: "A song", categoryIds: ["c-music"], }); route("GET /api/dumps", () => page([song])); const { socket } = setup("/music/new"); await screen.findByText("A song"); socket.receive({ type: "dump_updated", dump: wire({ ...song, categoryIds: [] }), }); expect(screen.queryByText("A song")).not.toBeInTheDocument(); }); test("an unknown category is a 404", async () => { route("GET /api/dumps", () => fail("nope", 404)); setup("/nonsense/new"); expect(await screen.findByRole("heading", { name: "404" })) .toBeInTheDocument(); }); test("a guest on the follow tab is sent to sign in, and back", () => { route("GET /api/dumps", () => page([older])); setup("/music/follow"); expect(currentPath()).toBe("/login?next=%2Fmusic%2Ffollow"); }); test("the Android share sheet opens the new-dump form with the link", async () => { route("GET /api/dumps", () => page([])); route( "GET /api/remote/preview", () => ({ reached: false, richContent: null }), ); route("GET /api/dumps", () => []); setup("/new?share_url=https%3A%2F%2Fshared.test%2F", { session: makeSession(), }); const dialog = await screen.findByRole("dialog", { name: "New dump" }); expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue( "https://shared.test/", ); }); }); describe("Followed feed", () => { const followCalls = () => calls("GET /api/dumps").filter((r) => r.url.searchParams.has("follow")); test("from people at /follow, from playlists at /follow/playlists", async () => { const session = makeSession(); route( "GET /api/dumps", (req) => page( req.url.searchParams.get("follow") === "users" ? [makeDump({ title: "By a friend" })] : [], ), ); const { user } = setup("/follow", { session }); await screen.findByText("By a friend"); await user.click(screen.getByRole("button", { name: "From playlists" })); expect(currentPath()).toBe("/follow/playlists"); expect( screen.getByText("Follow some public playlists to see their dumps here."), ).toBeInTheDocument(); const [feed] = followCalls(); expect(feed.headers.get("Authorization")).toBe(`Bearer ${session.token}`); await user.click(screen.getByRole("button", { name: "From people" })); expect(currentPath()).toBe("/follow"); }); test("a followed user's new dump arrives live, others' don't", async () => { route("GET /api/me/follow", () => ({ followedUserIds: ["friend"], followedPlaylistIds: [], })); route( "GET /api/dumps", (req) => page( req.url.searchParams.get("follow") === "users" ? [ makeDump({ title: "By a friend", userId: "friend", createdAt: new Date(Date.now() - 60_000), }), ] : [], ), ); const { socket } = setup("/follow", { session: makeSession() }); await screen.findByText("By a friend"); // The follows load beside the feed: their answer must be in. await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1)); await act(() => new Promise((r) => setTimeout(r, 0))); socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Fresh", userId: "friend" })), }); socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "A stranger's", userId: "stranger" })), }); expect(screen.getByText("Fresh")).toBeInTheDocument(); expect(screen.queryByText("A stranger's")).not.toBeInTheDocument(); }); test("a followed playlist's new dumps bring its view up to date", async () => { route("GET /api/me/follow", () => ({ followedUserIds: [], followedPlaylistIds: ["mix"], })); route("GET /api/dumps", () => page([]), { once: true }); route("GET /api/dumps", () => page([]), { once: true }); route( "GET /api/dumps", (req) => page( req.url.searchParams.get("follow") === "playlists" ? [makeDump({ title: "Added to the mix" })] : [], ), ); const { socket } = setup("/follow/playlists", { session: makeSession() }); await screen.findByText( "Follow some public playlists to see their dumps here.", ); await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1)); await act(() => new Promise((r) => setTimeout(r, 0))); socket.receive({ type: "playlist_dumps_updated", playlistId: "mix", dumpIds: ["x"], dumpCount: 1, }); expect(await screen.findByText("Added to the mix")).toBeInTheDocument(); }); test("its view is in the path, a category's too", async () => { route("GET /api/dumps", () => page([])); setup("/music/follow/playlists", { session: makeSession() }); await screen.findByText( "Follow some public playlists to see their dumps here.", ); expect( followCalls().map((r) => [ r.url.searchParams.get("follow"), r.url.searchParams.get("category"), ]).sort(), ).toEqual([["playlists", "music"], ["users", "music"]]); }); test("a server error reads as one, not as a lost connection", async () => { route( "GET /api/dumps", (req) => req.url.searchParams.get("follow") === "users" ? fail("Internal error", 500) : page([]), ); setup("/follow", { session: makeSession() }); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Internal error"); expect(alert).not.toHaveTextContent(/Could not connect/); }); });