import { describe, expect, test } from "vitest"; import { act, screen, waitFor } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { Search } from "../../pages/Search.tsx"; import { calls, fail, route } from "../fetch.ts"; import { makeCategory, makeDump, makePlaylist, makeSession, makeUser, wire, } from "../fixtures.ts"; import { currentPath, renderApp } from "../render.tsx"; import { FakeIntersectionObserver } from "../setup.ts"; import { FakeWebSocket } from "../ws.ts"; function results( { dumps = [] as ReturnType[], hasMore = false, users = [] as ReturnType[], playlists = [] as ReturnType[], } = {}, ) { return { dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore, }, users: wire(users), playlists: wire(playlists), }; } function open( url: string, session: ReturnType | null = null, categories: ReturnType[] = [], ) { const user = userEvent.setup(); renderApp(, { route: url, path: "/search/*", session, categories }); return user; } describe("Search", () => { test("no query: a prompt, no request", () => { open("/search"); expect(screen.getByText("Enter a query to search.")).toBeInTheDocument(); expect(calls("GET /api/search")).toHaveLength(0); }); test("results by tab, with counts", async () => { route("GET /api/search", () => results({ dumps: [makeDump({ title: "Cat video" })], users: [makeUser({ username: "catlover", description: "meow" })], playlists: [makePlaylist({ title: "Cats" })], })); const user = open("/search?q=cat", makeSession()); await screen.findByText("Cat video"); const [req] = calls("GET /api/search"); expect(req.url.searchParams.get("q")).toBe("cat"); expect(req.headers.get("Authorization")).toMatch(/^Bearer /); await user.click(screen.getByRole("button", { name: "Users (1)" })); expect(currentPath()).toBe("/search/users?q=cat"); expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute( "href", "/@catlover", ); await user.click(screen.getByRole("button", { name: "Playlists (1)" })); expect(screen.getByText("Cats")).toBeInTheDocument(); }); test("each tab narrows to a category of its own kind", async () => { route("GET /api/search", () => results({ dumps: [makeDump({ title: "Cat song" })], playlists: [makePlaylist({ title: "Cat mix" })], })); const user = open("/search?q=cat", null, [ makeCategory({ id: "dc1", slug: "music", name: "Music" }), makeCategory({ id: "pc1", kind: "playlist", slug: "mixes", name: "Mixes", }), ]); await screen.findByText("Cat song"); await user.click(screen.getByRole("button", { name: "All" })); expect(screen.queryByRole("option", { name: "Mixes" })).toBeNull(); await user.click(screen.getByRole("option", { name: "Music" })); expect(currentPath()).toBe("/search?q=cat&category=music"); await waitFor(() => expect( calls("GET /api/search").at(-1)!.url.searchParams.get("category"), ).toBe("music") ); // Another tab drops it, and offers its own. await user.click(screen.getByRole("button", { name: /^Playlists/ })); expect(currentPath()).toBe("/search/playlists?q=cat"); await user.click(await screen.findByRole("button", { name: "All" })); expect(screen.queryByRole("option", { name: "Music" })).toBeNull(); await user.click(screen.getByRole("option", { name: "Mixes" })); await waitFor(() => { const req = calls("GET /api/search").at(-1)!; expect(req.url.searchParams.get("playlistCategory")).toBe("mixes"); expect(req.url.searchParams.get("category")).toBeNull(); }); // The users tab has none. await user.click(screen.getByRole("button", { name: /^Users/ })); expect(screen.queryByRole("button", { name: "All" })).toBeNull(); }); test("nothing found", async () => { route("GET /api/search", () => results()); const user = open("/search?q=zzz"); expect(await screen.findByText('No dumps match "zzz".')) .toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Users (0)" })); expect(screen.getByText('No users match "zzz".')).toBeInTheDocument(); }); test("a failed search says why", async () => { route("GET /api/search", () => fail("Query too short", 400)); open("/search?q=a"); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Search failed"); expect(alert).toHaveTextContent("Query too short"); }); test("more dumps on scroll; a failed page keeps what's shown", async () => { route( "GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true }, ); route("GET /api/search", () => new TypeError("offline"), { once: true }); route( "GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] }), ); open("/search?q=x"); await screen.findByText("First"); const scroll = () => { const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0 )!; act(() => obs.report(true)); }; scroll(); await act(() => new Promise((r) => setTimeout(r, 50))); expect(screen.getByText("First")).toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); // Scrolling away and back tries again at once. const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0 )!; act(() => obs.report(false)); scroll(); await screen.findByText("Second"); expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe( "2", ); }); test("a later page that keeps failing isn't asked for in a loop", async () => { route( "GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true }, ); route("GET /api/search", () => fail("down", 500)); // A short page: the sentinel is on screen and stays there. FakeIntersectionObserver.inView = true; open("/search?q=x"); await screen.findByText("First"); // In short steps: React holds what an act() schedules until it ends. for (let i = 0; i < 20; i++) { await act(() => new Promise((r) => setTimeout(r, 100))); } const pages = calls("GET /api/search").filter((c) => c.url.searchParams.get("page") !== "1" ); // Once at once, once more a second later; the next is 2s after that. expect(pages).toHaveLength(2); expect(screen.getByText("First")).toBeInTheDocument(); }); test("a new query replaces the results", async () => { route( "GET /api/search", (req) => results({ dumps: [ makeDump({ title: `About ${req.url.searchParams.get("q")}` }), ], }), ); const user = open("/search?q=one"); await screen.findByText("About one"); const [box] = screen.getAllByRole("searchbox"); await user.clear(box); await user.type(box, "two{Enter}"); await screen.findByText("About two"); expect(screen.queryByText("About one")).not.toBeInTheDocument(); }); test("the socket keeps the results live, and adds none", async () => { const found = makeDump({ id: "d1", title: "Cat video" }); route("GET /api/search", () => results({ dumps: [found, makeDump({ id: "d2", title: "Cat photo" })], playlists: [makePlaylist({ id: "p1", title: "Cats" })], })); const user = open("/search?q=cat"); const socket = FakeWebSocket.last; socket.welcome(); await screen.findByText("Cat video"); socket.receive({ type: "dump_updated", dump: wire({ ...found, title: "Cat video, remastered" }), }); socket.receive({ type: "dump_deleted", dumpId: "d2" }); socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Cat news" })), }); expect(screen.getByText("Cat video, remastered")).toBeInTheDocument(); expect(screen.queryByText("Cat photo")).not.toBeInTheDocument(); expect(screen.queryByText("Cat news")).not.toBeInTheDocument(); socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "u" }); await user.click(screen.getByRole("button", { name: /Playlists/ })); expect(screen.queryByText("Cats")).not.toBeInTheDocument(); }); test("a reconnect runs the search again, for every page loaded", async () => { route( "GET /api/search", (req) => req.url.searchParams.get("page") === "2" ? results({ dumps: [makeDump({ title: "Second page" })] }) : results({ dumps: [makeDump({ title: "First page" })], hasMore: true, users: [makeUser({ username: "catlover" })], }), ); open("/search?q=cat"); const socket = FakeWebSocket.last; socket.welcome(); await screen.findByText("First page"); const sentinel = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0 )!; act(() => sentinel.report(true)); await screen.findByText("Second page"); // Gone while the socket was down: an edit, a deletion, a new user. route( "GET /api/search", (req) => req.url.searchParams.get("page") === "2" ? results({ dumps: [] }) : results({ dumps: [makeDump({ title: "First page, edited" })], hasMore: true, users: [ makeUser({ username: "catlover" }), makeUser({ username: "cat2" }), ], }), ); socket.drop(); await act(() => new Promise((r) => setTimeout(r, 600))); FakeWebSocket.last.welcome(); expect(await screen.findByText("First page, edited")).toBeInTheDocument(); expect(screen.queryByText("Second page")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Users (2)" })) .toBeInTheDocument(); }); });