Files
siftlode/frontend/src/components/SavedViewsProvider.dom.test.tsx
T
peter 5d7be687d3 test(views,layer): close the two polish-tail deep-review test gaps
UX-8 layerStack: add an integration test wired through the real useLayer
hook (popover document-listener + stamp) and a PlayerModal-shaped window
listener, driven by a real dispatched keydown. The popover close is wrapped
in flushSync so its layer is popped synchronously between the two listeners
-- the production mid-dispatch race -- making the escapeHandled stamp the
only thing that keeps the player from also closing. Mutation-checked:
removing the guard fails exactly this case.

SavedViews: cover feedCanSeedDefault's "yield to a bookmarked filter URL"
branch (a filter-bearing search string -> no seed) and the per-source
seededRef guard against the 60s refetch (a changed-list refetch re-runs the
seed effect; the guard blocks re-seed). The refetch test waits for the
changed list to reach the component so the effect has its chance to wrongly
re-fire. Both mutation-checked.

Frontend gate green: 464 vitest (+4). Backend untouched.
2026-08-24 23:41:49 +02:00

192 lines
8.5 KiB
TypeScript

// @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<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./NavigationProvider", () => ({ useNavigation: () => ({ page: h.page }) }));
vi.mock("./FeedFiltersProvider", () => ({
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("./PlexProvider", () => ({
usePlex: () => ({ filters: {} }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
import { api, setActiveAccount, 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>): SavedView => ({
id: 1,
name: "v",
filters: {},
position: 0,
is_default: false,
...over,
});
function Probe() {
const { loading, views } = useSavedViews();
return (
<div data-testid="loading" data-count={views.length}>
{String(loading)}
</div>
);
}
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 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
// 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: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => {
// The provider mounts above <App>, which pins the tab-account only after /api/me; the ungated
// views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its
// one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload).
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
// App pins the account; the reactive id re-fires the effect and the deferred seed lands.
act(() => setActiveAccount(ID));
await waitFor(() => 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 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
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();
});
});