// 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 // Every field set (Required: a new filter field fails tsc here until it has a value, so its key // is checked too). `year` wins over the other date filters, so the set goes out once with it // and once without. const all: Required = { tags: [1], tagModes: { language: "and", topic: "and", other: "and" }, q: "x", sort: "shuffle", seed: 1, scope: "all", includeNormal: false, includeShorts: true, includeLive: true, show: "all", librarySource: "search", untagged: true, channelIds: ["UC"], maxAgeDays: 3, minDuration: 1, maxDuration: 2, dateFrom: "2024-01-01", dateTo: "2024-01-02", year: 2020, }; const everything = [...sent(all, "C").keys(), ...sent({ ...all, year: undefined }, "C").keys()]; const unknown = [...new Set(everything)].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 }); }); it("a related list is quiet too, and sends its region and cursor", () => { void feedApi.related("a/b", "hu", "CUR1"); const [url, , opts] = vi.mocked(req).mock.lastCall!; const u = new URL(url as string, "http://x"); expect(u.pathname).toBe("/api/search/related/a%2Fb"); expect(Object.fromEntries(u.searchParams)).toEqual({ region: "hu", cursor: "CUR1" }); expect(opts).toEqual({ quiet: true }); void feedApi.related("abc", "topical", null); const first = new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x"); expect(Object.fromEntries(first.searchParams)).toEqual({ region: "topical" }); }); });