Files
biggus-dickus/web/src/search/search.test.tsx
T
logaritmisk ed0c13907c refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46)
Convert app.tsx route tree verbatim to a module-level data router via
createRoutesFromElements + RouterProvider, and the test harness to
createMemoryRouter + RouterProvider. The search NavLink-click test now mounts
its routes as real data-router routes so RouterProvider intercepts the link
(descendant <Routes> under a catch-all let it fall through to a jsdom navigation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:07:03 +02:00

136 lines
5.3 KiB
TypeScript

import { expect, test } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, Route, RouterProvider, Routes } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { amphora } from "../test/fixtures";
import { SearchPage } from "./search-page";
import { SelectSearchPrompt } from "./select-search-prompt";
import { ObjectDetail } from "../objects/object-detail";
import "../i18n";
function tree() {
return (
<Routes>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
</Routes>
);
}
// The search rows are <NavLink>s. Under the shared `renderApp` harness the test
// subtree lives in a descendant <Routes> under a catch-all `*` data route, where
// the data router does not intercept the link click (it falls through to a real
// browser navigation that jsdom rejects). Mounting the search routes as real
// data-router routes lets RouterProvider intercept the NavLink, which is the
// data-router equivalent of the old MemoryRouter behavior.
function renderSearchRouter(route = "/search") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const router = createMemoryRouter(
[
{
path: "/search",
element: <SearchPage />,
children: [
{ index: true, element: <SelectSearchPrompt /> },
{ path: ":id", element: <ObjectDetail /> },
],
},
],
{ initialEntries: [route] },
);
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
test("typing searches and renders highlighted rich rows", async () => {
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
const mark = await screen.findByText("bronze");
expect(mark.tagName).toBe("MARK");
expect(screen.getByText(/25 results/i)).toBeInTheDocument();
});
test("Load more appends the next page", async () => {
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
await screen.findByText("Bronze figurine");
expect(screen.queryByText("Object 21")).toBeNull();
await userEvent.click(screen.getByRole("button", { name: /load more/i }));
expect(await screen.findByText("Object 21")).toBeInTheDocument();
});
test("the visibility filter adds the param to the request", async () => {
let lastVisibility: string | null = "unset";
server.use(
http.get("/api/admin/search", ({ request }) => {
lastVisibility = new URL(request.url).searchParams.get("visibility");
return HttpResponse.json({ hits: [], estimated_total: 0 });
}),
);
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
await userEvent.click(screen.getByRole("button", { name: /^draft$/i }));
await waitFor(() => expect(lastVisibility).toBe("draft"));
});
test("empty query shows the prompt; zero results shows empty", async () => {
renderApp(tree(), { route: "/search" });
expect(screen.getByText(/type to search/i)).toBeInTheDocument();
server.use(
http.get("/api/admin/search", () => HttpResponse.json({ hits: [], estimated_total: 0 })),
);
await userEvent.type(screen.getByLabelText(/search the collection/i), "zzz");
expect(await screen.findByText(/no results/i)).toBeInTheDocument();
});
test("clicking a result shows the object in the detail pane", async () => {
renderSearchRouter();
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
await userEvent.click(await screen.findByText("Bronze figurine"));
expect(await screen.findByText(amphora.object_name)).toBeInTheDocument();
});
test("a 503 shows the search-unavailable message", async () => {
server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 503 })));
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
expect(await screen.findByText(/not available on this server/i)).toBeInTheDocument();
});
test("a 500 shows the generic search error", async () => {
server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 500 })));
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
expect(await screen.findByText(/^search is unavailable$/i)).toBeInTheDocument();
});
test("hydrates query and visibility from the initial URL", async () => {
renderApp(tree(), { route: "/search?q=bronze" });
expect(screen.getByLabelText(/search the collection/i)).toHaveValue("bronze");
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
const { container } = renderApp(tree(), { route: "/search?q=bronze&visibility=internal" });
expect(
within(container).getByRole("button", { name: /^internal$/i }),
).toHaveAttribute("aria-pressed", "true");
});