167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
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<typeof makeDump>[],
|
|
hasMore = false,
|
|
users = [] as ReturnType<typeof makeUser>[],
|
|
playlists = [] as ReturnType<typeof makePlaylist>[],
|
|
} = {},
|
|
) {
|
|
return {
|
|
dumps: {
|
|
items: wire(dumps),
|
|
total: dumps.length + (hasMore ? 1 : 0),
|
|
hasMore,
|
|
},
|
|
users: wire(users),
|
|
playlists: wire(playlists),
|
|
};
|
|
}
|
|
|
|
function open(
|
|
url: string,
|
|
session: ReturnType<typeof makeSession> | null = null,
|
|
) {
|
|
const user = userEvent.setup();
|
|
renderApp(<Search />, { 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();
|
|
});
|
|
});
|