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 });
});
});