feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime

This commit is contained in:
Silo Server Migration
2026-05-25 18:12:19 -04:00
parent 4f0ecbe3c0
commit e97286da01
2 changed files with 132 additions and 6 deletions
+110
View File
@@ -0,0 +1,110 @@
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";
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("uses a 5-minute staleTime", () => {
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(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);
});
});
+22 -6
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import type {
CreateMediaRequestInput,
DiscoverBrowseKind,
@@ -28,6 +29,7 @@ import type {
import { adminKeys, requestKeys } from "./keys";
const REQUESTS_STALE_TIME = 30_000;
const REQUEST_SEARCH_STALE_TIME = 5 * 60 * 1000;
const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000;
const BROWSE_STALE_TIME = 60 * 1000;
@@ -148,20 +150,34 @@ export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: numbe
});
}
export function useRequestSearch(mediaType: RequestSearchMediaType, query: string, page = 1) {
export interface UseRequestSearchOptions {
/** When false, suppresses the query regardless of the query string. Default: true. */
enabled?: boolean;
}
export function useRequestSearch(
mediaType: RequestSearchMediaType,
query: string,
page = 1,
options: UseRequestSearchOptions = {},
) {
const normalizedQuery = query.trim();
const { profile } = useCurrentProfile();
const viewerKey = profile?.id ?? "anon";
const enabledOverride = options.enabled ?? true;
return useQuery({
queryKey: requestKeys.search(mediaType, normalizedQuery, page),
queryFn: () => {
queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey),
queryFn: ({ signal }) => {
const params = new URLSearchParams({
q: normalizedQuery,
media_type: mediaType,
page: String(page),
});
return api<RequestMediaPage>(`/requests/search?${params}`);
return api<RequestMediaPage>(`/requests/search?${params}`, { signal });
},
enabled: normalizedQuery.length > 1,
staleTime: REQUESTS_STALE_TIME,
enabled: enabledOverride && normalizedQuery.length > 1,
staleTime: REQUEST_SEARCH_STALE_TIME,
});
}