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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user