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.
This commit is contained in:
2026-10-03 21:53:06 +02:00
parent 2e2d6f5746
commit fb6b56f121
5 changed files with 211 additions and 17 deletions
+162
View File
@@ -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<FeedFilters>, 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 });
});
});
-8
View File
@@ -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<FeedResponse> =>
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<string, number> }> =>
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<string, number> }> =>
@@ -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);
});
});
-4
View File
@@ -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"]);
+1 -5
View File
@@ -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) =>