288 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|