From e97286da01e5626ea51f90f9517fe0e296cbbaf3 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:12:19 -0400 Subject: [PATCH] feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime --- web/src/hooks/queries/useRequests.test.tsx | 110 +++++++++++++++++++++ web/src/hooks/queries/useRequests.ts | 28 ++++-- 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 web/src/hooks/queries/useRequests.test.tsx diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx new file mode 100644 index 00000000..5767e33a --- /dev/null +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -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("@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({node}); +} + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(true); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 1566ee08..4ba1bab9 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -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(`/requests/search?${params}`); + return api(`/requests/search?${params}`, { signal }); }, - enabled: normalizedQuery.length > 1, - staleTime: REQUESTS_STALE_TIME, + enabled: enabledOverride && normalizedQuery.length > 1, + staleTime: REQUEST_SEARCH_STALE_TIME, }); }