// @vitest-environment jsdom // // S2 — the single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider // seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH // the feed and Plex — but leaves them alone once the account has its own stored filters. This is the // fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a // Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called; // api.savedViews is stubbed and driven through a real QueryClient. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, cleanup, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const h = vi.hoisted(() => ({ page: "feed" as "feed" | "plex", setFeedFilters: vi.fn(), setPlexFilters: vi.fn(), })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page: h.page }) })); vi.mock("./feedFiltersContext", () => ({ useFeedFilters: () => ({ tags: [], channelIds: [], tagModes: { language: "or", topic: "or", other: "or" }, q: "", sort: "newest", scope: "my", includeNormal: true, includeShorts: false, includeLive: false, show: "unwatched", }), useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }), })); vi.mock("./plexContext", () => ({ usePlex: () => ({ filters: {} }), usePlexActions: () => ({ setFilters: h.setPlexFilters }), })); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); import { SavedViewsProvider } from "./SavedViewsProvider"; import { useSavedViews } from "./savedViewsContext"; import { api, type SavedView } from "../lib/api"; import { LS, accountKey } from "../lib/storage"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; const ID = 7; const view = (over: Partial): SavedView => ({ id: 1, name: "v", filters: {}, position: 0, is_default: false, ...over, }); function Probe() { const { loading, views } = useSavedViews(); return (
{String(loading)}
); } // The views query waits for a signed-in account in the cache (BootGate's `me`). function signedInClient() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: ID, preferences: {} }); return qc; } async function mountResolved(views: SavedView[]) { // Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's // identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing // in the re-seed test; a shared reference would skip the effect and hide a missing guard). vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v }))); const qc = signedInClient(); render( , ); // Wait for the query to resolve so the first-resolve effect has run. await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false")); return qc; } beforeEach(() => { localStorage.clear(); sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID)); // Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior // case would suppress seeding in the others. window.history.replaceState({}, "", "/"); h.page = "feed"; h.setFeedFilters.mockClear(); h.setPlexFilters.mockClear(); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe("SavedViewsProvider — default-view seeding on first resolve", () => { it("feed: seeds the starred default into a fresh module (no stored filters)", async () => { await mountResolved([ view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }), ]); expect(h.setFeedFilters).toHaveBeenCalledTimes(1); expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" }); }); it("feed: does NOT seed when the account already has stored filters", async () => { localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" })); await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]); expect(h.setFeedFilters).not.toHaveBeenCalled(); }); it("feed: does NOT seed when no view is starred as default", async () => { await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]); expect(h.setFeedFilters).not.toHaveBeenCalled(); }); it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => { // A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not // stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only. window.history.replaceState({}, "", "/?sort=oldest"); await mountResolved([ view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }), ]); expect(h.setFeedFilters).not.toHaveBeenCalled(); }); it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => { // The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a // view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must // stop it re-applying the default over filters the user has since edited. (An identical refetch is // deduped by structural sharing and never reaches the effect, so the changed list is the real test.) const def = view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }); let calls = 0; vi.spyOn(api, "savedViews").mockImplementation(async () => { calls += 1; return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })]; }); const qc = signedInClient(); render( , ); await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false")); expect(h.setFeedFilters).toHaveBeenCalledTimes(1); await act(async () => { await qc.refetchQueries({ queryKey: qk.savedViews("feed") }); }); // Wait until the changed list has actually reached the component, so the seed effect has had its // chance to (wrongly) re-fire — otherwise the assertion could win by racing ahead of the re-render. await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2")); expect(h.setFeedFilters).toHaveBeenCalledTimes(1); }); it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => { h.page = "plex"; await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]); expect(h.setPlexFilters).toHaveBeenCalledTimes(1); expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] }); }); it("plex: does NOT seed when plex filters are already stored", async () => { h.page = "plex"; localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] })); await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]); expect(h.setPlexFilters).not.toHaveBeenCalled(); }); }); describe("SavedViewsProvider — signed-out tab", () => { it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => { const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), null); // BootGate's "nobody" render( , ); // Let any fetch the mount would start get going before asserting it never happened. await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(spy).not.toHaveBeenCalled(); // …and it asks as soon as an account signs in. act(() => qc.setQueryData(qk.me(), { id: ID, preferences: {} })); await waitFor(() => expect(spy).toHaveBeenCalledWith("feed")); }); });