import { describe, expect, test } from "vitest"; import { act, screen } 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 { makeDump, makePlaylist, makeSession, makeUser, wire, } from "../fixtures.ts"; import { currentPath, renderApp } from "../render.tsx"; import { FakeIntersectionObserver } from "../setup.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, ) { const user = userEvent.setup(); renderApp(, { route: url, path: "/search/*", session }); 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("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(); }); });