feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user