Files
gerbeur/src/test/pages/Search.test.tsx

288 lines
10 KiB
TypeScript

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<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,
categories: ReturnType<typeof makeCategory>[] = [],
) {
const user = userEvent.setup();
renderApp(<Search />, { 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();
});
});