From fb6b56f1216d640617c2d1883876aa321823b5d6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 21:53:06 +0200 Subject: [PATCH] test(api): every filter key the client sends is one the backend reads FastAPI ignores an unknown query parameter, so a misspelt or renamed key silently stops a filter while its chip still shows. Contract tests read routes/feed.py _feed_params and plex/query.py LibraryFilters. The superseded feedCount/facets calls and keys are removed. --- frontend/src/lib/api/feed.test.ts | 162 +++++++++++++++++++ frontend/src/lib/api/feed.ts | 8 - frontend/src/lib/api/plexFilters.dom.test.ts | 48 ++++++ frontend/src/lib/queryKeys.test.ts | 4 - frontend/src/lib/queryKeys.ts | 6 +- 5 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/api/feed.test.ts create mode 100644 frontend/src/lib/api/plexFilters.dom.test.ts diff --git a/frontend/src/lib/api/feed.test.ts b/frontend/src/lib/api/feed.test.ts new file mode 100644 index 00000000..fed12e06 --- /dev/null +++ b/frontend/src/lib/api/feed.test.ts @@ -0,0 +1,162 @@ +// E32 S2: lib/api/feed was barely covered, and it is where a filter turns into a query string. +// FastAPI IGNORES a query parameter it does not know, so a misspelt or renamed key does not fail — +// the filter simply stops applying while its chip still shows. Locked two ways: what each filter +// emits, and that every key emitted is one the backend's feed endpoints actually read. +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beacon, req } from "./core"; +import { feedApi, type FeedFilters } from "./feed"; + +vi.mock("./core", () => ({ req: vi.fn(() => Promise.resolve({})), beacon: vi.fn() })); + +const BASE: FeedFilters = { + tags: [], + tagModes: { language: "or", topic: "or", other: "or" }, + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", + channelIds: [], +}; + +const lastUrl = () => new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x"); +const sent = (f: Partial, cursor: string | null = null) => { + void feedApi.feed({ ...BASE, ...f }, cursor, 30); + return lastUrl().searchParams; +}; + +beforeEach(() => vi.mocked(req).mockClear()); + +describe("feed filters on the wire", () => { + it("the defaults: scope, content types, show, library source, sort and page size", () => { + const p = sent({}); + expect(Object.fromEntries(p)).toEqual({ + scope: "my", + show_normal: "true", + include_shorts: "false", + include_live: "false", + show: "unwatched", + library_source: "organic", + sort: "newest", + limit: "30", + }); + }); + + it("repeats tags and channels, and sends only the categories matched as ALL", () => { + const p = sent({ + tags: [3, 9], + tagModes: { language: "or", topic: "and", other: "or" }, + channelIds: ["UCa", "UCb"], + untagged: true, + q: "jazz", + }); + expect(p.getAll("tags")).toEqual(["3", "9"]); + expect(p.getAll("channel_ids")).toEqual(["UCa", "UCb"]); + expect(p.get("tag_mode_topic")).toBe("and"); + expect(p.has("tag_mode_language") || p.has("tag_mode_other")).toBe(false); + expect(p.get("untagged")).toBe("true"); + expect(p.get("q")).toBe("jazz"); + }); + + it("a year is a whole-year range and wins over the other date narrowings", () => { + const p = sent({ year: 2019, maxAgeDays: 7, dateFrom: "2024-01-01", dateTo: "2024-02-01" }); + expect(p.get("published_after")).toBe("2019-01-01"); + expect(p.get("published_before")).toBe("2019-12-31"); + expect(p.has("max_age_days")).toBe(false); + + const q = sent({ maxAgeDays: 7, dateFrom: "2024-01-01", dateTo: "2024-02-01" }); + expect([q.get("max_age_days"), q.get("published_after"), q.get("published_before")]).toEqual([ + "7", + "2024-01-01", + "2024-02-01", + ]); + }); + + it("a zero-second duration bound is still a bound", () => { + const p = sent({ minDuration: 0, maxDuration: 60 }); + expect([p.get("min_duration"), p.get("max_duration")]).toEqual(["0", "60"]); + }); + + it("the shuffle seed travels only with the shuffle sort; the cursor when paging", () => { + expect(sent({ sort: "shuffle", seed: 42 }).get("seed")).toBe("42"); + expect(sent({ sort: "newest", seed: 42 }).has("seed")).toBe(false); + expect(sent({}, "CUR").get("cursor")).toBe("CUR"); + }); + + it("the summary and the year list see the same filters, without the paging", () => { + void feedApi.feedSummary({ ...BASE, tags: [5] }); + expect(lastUrl().pathname).toBe("/api/feed/summary"); + expect(lastUrl().searchParams.getAll("tags")).toEqual(["5"]); + expect(lastUrl().searchParams.has("limit")).toBe(false); + void feedApi.feedYears({ ...BASE, year: 2020 }); + expect(lastUrl().pathname).toBe("/api/feed/years"); + }); + + it("every key it sends is a parameter the backend's feed endpoints read", () => { + const routes = readFileSync( + new URL("../../../../backend/app/routes/feed.py", import.meta.url), + "utf-8", + ); + const block = routes.match(/def _feed_params\(([\s\S]*?)\) -> dict:/); + expect(block, "routes/feed.py no longer defines _feed_params").toBeTruthy(); + const known = new Set( + [...(block?.[1] ?? "").matchAll(/^\s{4}(\w+):/gm)].flatMap((m) => (m[1] ? [m[1]] : [])), + ); + known.add("sort").add("seed").add("cursor").add("limit"); // read by /feed itself + const everything = sent( + { + tags: [1], + tagModes: { language: "and", topic: "and", other: "and" }, + channelIds: ["UC"], + untagged: true, + q: "x", + maxAgeDays: 3, + dateFrom: "2024-01-01", + dateTo: "2024-01-02", + minDuration: 1, + maxDuration: 2, + sort: "shuffle", + seed: 1, + }, + "C", + ); + const unknown = [...new Set(everything.keys())].filter((k) => !known.has(k)); + expect(unknown).toEqual([]); + expect(known.has("tag_mode_topic")).toBe(true); // the parse found the real list + }); +}); + +describe("watch state and progress", () => { + it("progress is saved in whole seconds, and the unload beacon escapes the id", () => { + void feedApi.saveProgress("abc", 61.9, 600.4); + const [url, init, opts] = vi.mocked(req).mock.lastCall!; + expect(url).toBe("/api/videos/abc/progress"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + position_seconds: 61, + duration_seconds: 600, + }); + expect(opts).toEqual({ idempotent: true }); + + feedApi.saveProgressBeacon("a/b", 10.5, 20.5); + expect(vi.mocked(beacon)).toHaveBeenLastCalledWith("/api/videos/a%2Fb/progress", { + position_seconds: 10, + duration_seconds: 20, + }); + }); + + it("the live search is quiet, so its panel can show a quota error inline", () => { + void feedApi.searchYoutube("cats & dogs", "NEXT", 10); + const [url, , opts] = vi.mocked(req).mock.lastCall!; + const u = new URL(url as string, "http://x"); + expect(u.pathname).toBe("/api/search/youtube"); + expect(Object.fromEntries(u.searchParams)).toEqual({ + q: "cats & dogs", + cursor: "NEXT", + limit: "10", + }); + expect(opts).toEqual({ quiet: true }); + }); +}); diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index dcdf133d..4f0a24b5 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -151,10 +151,6 @@ function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string export const feedApi = { feed: (f: FeedFilters, cursor: string | null, limit: number): Promise => req(`/api/feed?${feedQuery(f, cursor, limit)}`), - // SUPERSEDED by feedSummary (PERF-13) — no component calls this anymore; the backend /feed/count - // endpoint it targets is kept only as the /feed/summary equivalence-test oracle. Retire together. - feedCount: (f: FeedFilters): Promise<{ count: number }> => - req(`/api/feed/count?${filterParams(f).toString()}`), // Live YouTube search: results are materialised into the catalog and returned as feed cards. // `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of // popping the global modal. `cursor` is the next-page token (an InnerTube continuation token @@ -169,10 +165,6 @@ export const feedApi = { // the now-orphaned, un-kept videos). Returns how many videos were removed. clearSearch: (videoIds: string[]): Promise<{ removed: number }> => req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }), - // SUPERSEDED by feedSummary (PERF-13) — no component calls this anymore; the backend /facets - // endpoint it targets is kept only as the /feed/summary equivalence-test oracle. Retire together. - facets: (f: FeedFilters): Promise<{ counts: Record }> => - req(`/api/facets?${filterParams(f).toString()}`), // The sidebar's total count + per-tag channel facets in ONE request + ONE grouped query, replacing // the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13). feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record }> => diff --git a/frontend/src/lib/api/plexFilters.dom.test.ts b/frontend/src/lib/api/plexFilters.dom.test.ts new file mode 100644 index 00000000..f5bbe656 --- /dev/null +++ b/frontend/src/lib/api/plexFilters.dom.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { plexFiltersToParams, type PlexFilters } from "./plex"; + +// FastAPI ignores a query parameter it does not know, so a Plex filter key the frontend misspells or +// the backend renames does not fail anywhere — the filter silently stops narrowing while its chip +// still shows (E32 S2, the sibling of lib/api/feed.test.ts). Every key the filter set can put on the +// wire must be a field of the backend's LibraryFilters, which /library and /facets both inject. +const EVERYTHING: PlexFilters = { + genres: ["Drama"], + genreMode: "all", + contentRatings: ["PG"], + yearMin: 1990, + yearMax: 1999, + ratingMin: 7, + durationMin: 60, + durationMax: 7200, + addedWithin: "1w", + directors: ["A"], + actors: ["B"], + actorMode: "all", + studios: ["C"], + playable: ["direct"], + keywords: ["heist"], + collections: ["123"], + collectionMode: "all", + collectionTitles: { "123": "Display only" }, +}; + +describe("Plex filters on the wire", () => { + it("every key sent is a LibraryFilters field the backend reads", () => { + const src = readFileSync(resolve(__dirname, "../../../../backend/app/plex/query.py"), "utf-8"); + const block = src.match(/class LibraryFilters:[\s\S]*?"""[\s\S]*?"""([\s\S]*?)def as_dict/); + expect(block, "plex/query.py no longer defines LibraryFilters").toBeTruthy(); + const fields = new Set( + [...(block?.[1] ?? "").matchAll(/^\s{4}(\w+):/gm)].flatMap((m) => (m[1] ? [m[1]] : [])), + ); + expect(fields.has("collection_mode")).toBe(true); // the parse found the real list + + const sent = [...plexFiltersToParams(EVERYTHING).keys()]; + expect(sent.filter((k) => !fields.has(k))).toEqual([]); + // Every filter in the set reached the wire (display-only titles excepted), so the check + // above saw them all. + expect(sent).toHaveLength(Object.keys(EVERYTHING).length - 1); + }); +}); diff --git a/frontend/src/lib/queryKeys.test.ts b/frontend/src/lib/queryKeys.test.ts index 88b035d2..645e7573 100644 --- a/frontend/src/lib/queryKeys.test.ts +++ b/frontend/src/lib/queryKeys.test.ts @@ -12,10 +12,6 @@ describe("qk — bare vs parameterized branch", () => { it("collapses to the bare prefix key only when no arg is given", () => { expect(qk.feed()).toEqual(["feed"]); expect(qk.feed(FILTERS)).toEqual(["feed", FILTERS]); - expect(qk.feedCount()).toEqual(["feed-count"]); - expect(qk.feedCount(FILTERS)).toEqual(["feed-count", FILTERS]); - expect(qk.facets()).toEqual(["facets"]); - expect(qk.facets(FILTERS)).toEqual(["facets", FILTERS]); expect(qk.feedSummary()).toEqual(["feed-summary"]); expect(qk.feedSummary(FILTERS)).toEqual(["feed-summary", FILTERS]); expect(qk.playlist()).toEqual(["playlist"]); diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 7c07ca10..13d608ca 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -6,7 +6,7 @@ import type { FeedFilters } from "./api"; // SAME array shape the call sites used before, so TanStack's partial (prefix) matching is // unchanged — `qk.feed()` (`["feed"]`) still invalidates `qk.feed(filters)` (`["feed", filters]`). // Deliberately NOT bundling co-invalidations into helpers here: the clusters differ per call site -// (some invalidate `feed` alone, others `feed` + `feed-count`), so collapsing them would change +// (some invalidate `feed` alone, others `feed` + `feed-summary`), so collapsing them would change // which queries refetch. Grouped invalidation stays opt-in per domain (see `invalidateDownloads`). // Extended per domain as R7 S2 migrates each area (S2a: feed/channels/playlists + shared roots). export const qk = { @@ -26,10 +26,6 @@ export const qk = { // silently collapsed to the prefix. feed: (filters?: FeedFilters) => filters !== undefined ? (["feed", filters] as const) : (["feed"] as const), - feedCount: (filters?: FeedFilters) => - filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const), - facets: (filters?: FeedFilters) => - filters !== undefined ? (["facets", filters] as const) : (["facets"] as const), feedSummary: (filters?: FeedFilters) => filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const), feedYears: (filters?: FeedFilters) =>