Files
silo-server/web/src/hooks/queries/useRequests.test.tsx
T

173 lines
6.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { requestKeys } from "./keys";
const mocks = vi.hoisted(() => ({
useQuery: vi.fn(),
useCurrentProfile: vi.fn(),
api: vi.fn(),
}));
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
return {
...actual,
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
};
});
vi.mock("@/hooks/useCurrentProfile", () => ({
useCurrentProfile: () => mocks.useCurrentProfile(),
}));
vi.mock("@/api/client", () => ({
api: (...args: unknown[]) => mocks.api(...args),
}));
import { useRequestSearch } from "./useRequests";
function render(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
}
function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) {
useRequestSearch(props.mediaType, props.q, props.page ?? 1);
return null;
}
describe("useRequestSearch", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useCurrentProfile.mockReset();
mocks.api.mockReset();
});
it("includes the current profile id in the query key", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] };
expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]);
});
it("uses 'anon' as the viewer key when there is no profile", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: null });
render(<CallHook mediaType="movie" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] };
expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]);
});
it("forwards the react-query signal to api()", async () => {
mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] });
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as {
queryFn: (ctx: { signal: AbortSignal }) => unknown;
};
const controller = new AbortController();
await options.queryFn({ signal: controller.signal });
expect(mocks.api).toHaveBeenCalledTimes(1);
const apiCall = mocks.api.mock.calls[0]!;
expect(apiCall[0]).toContain("/requests/search?");
const init = apiCall[1] as RequestInit;
expect(init.signal).toBe(controller.signal);
});
it("keeps the existing Requests page staleTime by default", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number };
expect(options.staleTime).toBe(30 * 1000);
});
it("allows callers to opt into a longer staleTime", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
function CallHookWithStaleTime() {
useRequestSearch("all", "dune", 1, { staleTime: 5 * 60 * 1000 });
return null;
}
render(<CallHookWithStaleTime />);
const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number };
expect(options.staleTime).toBe(5 * 60 * 1000);
});
it("respects the enabled option override", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
function CallHookWithOpt({ enabled }: { enabled: boolean }) {
useRequestSearch("all", "dune", 1, { enabled });
return null;
}
render(<CallHookWithOpt enabled={false} />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(false);
});
it("does not include enabled override when option omitted (defaults to true)", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(true);
});
it("does not require profile by default", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: null });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(true);
});
it("can require a profile before fetching", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: null });
function CallHookRequiringProfile() {
useRequestSearch("all", "dune", 1, { requireProfile: true });
return null;
}
render(<CallHookRequiringProfile />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(false);
});
});
describe("requestKeys.all invalidation", () => {
it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => {
const client = new QueryClient();
client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true });
expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({
sentinel: true,
});
await client.invalidateQueries({ queryKey: requestKeys.all });
const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1"));
expect(state?.isInvalidated).toBe(true);
});
});
describe("viewer-scoped cache isolation", () => {
it("does not return profile-1 results when keyed by profile-2", () => {
const client = new QueryClient();
client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), {
results: [{ tmdb_id: 1 }],
});
expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined();
});
});