test(fe): lock query-key factory shapes; unify nullable guard (R7 S2 review)
Code-review follow-ups on the S2 factory: - add queryKeys.test.ts pinning every branch (bare vs param, null-segment preservation, single-id, variadic composite spreads) — the contract the epic exists to protect now has a test (suite 68 -> 73) - unify feed/feedCount/facets on the `!== undefined` guard the rest of the factory already uses (behaviour-identical for the always-truthy FeedFilters, removes the collapse-on-null footgun) Two findings skipped with rationale: variadic composite-key typing (heterogeneous object/array segments make precise tuples high-effort; root centralized + shapes now tested) and a pre-existing thread(null) no-op (unreachable in practice).
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qk } from "./queryKeys";
|
||||
import type { FeedFilters } from "./api";
|
||||
|
||||
// The factory is the single source of truth for ~200 React Query keys; these tests lock the exact
|
||||
// array shapes it produces, because TanStack invalidation is prefix-MATCHING on those arrays — a
|
||||
// silent shape change (a flipped guard, a dropped spread) would break refetching with no type error.
|
||||
|
||||
const FILTERS = { scope: "my", tags: [] } as unknown as FeedFilters;
|
||||
|
||||
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.playlist()).toEqual(["playlist"]);
|
||||
expect(qk.plexShow()).toEqual(["plex-show"]);
|
||||
expect(qk.plexShow("s1")).toEqual(["plex-show", "s1"]);
|
||||
expect(qk.ytSearch()).toEqual(["yt-search"]);
|
||||
});
|
||||
|
||||
it("preserves a NULL segment instead of collapsing it to the prefix", () => {
|
||||
// `["playlist", null]` must stay 2-element — a null id is a real (distinct) key, not "no arg".
|
||||
expect(qk.playlist(null)).toEqual(["playlist", null]);
|
||||
expect(qk.playlist(7)).toEqual(["playlist", 7]);
|
||||
expect(qk.ytSearch(null, 3)).toEqual(["yt-search", null, 3]);
|
||||
expect(qk.ytSearch("cats", 3)).toEqual(["yt-search", "cats", 3]);
|
||||
expect(qk.thread(null)).toEqual(["thread", null]);
|
||||
expect(qk.thread(42)).toEqual(["thread", 42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("qk — single-id and scalar keys", () => {
|
||||
it("returns [root, id]", () => {
|
||||
expect(qk.channel("c1")).toEqual(["channel", "c1"]);
|
||||
expect(qk.videoDetail("v1")).toEqual(["video-detail", "v1"]);
|
||||
expect(qk.playlistMembership("v1")).toEqual(["playlist-membership", "v1"]);
|
||||
expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]);
|
||||
expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]);
|
||||
expect(qk.downloadLinks(5)).toEqual(["download-links", 5]);
|
||||
expect(qk.dlQuota(3)).toEqual(["dl-quota", 3]);
|
||||
expect(qk.storyboard(5)).toEqual(["storyboard", 5]);
|
||||
expect(qk.adminQuota(30)).toEqual(["admin-quota", 30]);
|
||||
expect(qk.plexPeopleCards("both", ["Actor"])).toEqual(["plex-people-cards", "both", ["Actor"]]);
|
||||
});
|
||||
|
||||
it("returns the bare root for no-arg keys", () => {
|
||||
expect(qk.me()).toEqual(["me"]);
|
||||
expect(qk.myStatus()).toEqual(["my-status"]);
|
||||
expect(qk.channels()).toEqual(["channels"]);
|
||||
expect(qk.playlists()).toEqual(["playlists"]);
|
||||
expect(qk.conversations()).toEqual(["conversations"]);
|
||||
expect(qk.downloads()).toEqual(["downloads"]);
|
||||
expect(qk.adminUsers()).toEqual(["admin-users"]);
|
||||
expect(qk.plex()).toEqual(["plex"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("qk — variadic composite plex keys", () => {
|
||||
it("spreads the rest args after the root, and is bare with none", () => {
|
||||
expect(qk.plexLibrary()).toEqual(["plex-library"]);
|
||||
expect(qk.plexLibrary("movie", "q", "title", "s", FILTERS)).toEqual([
|
||||
"plex-library",
|
||||
"movie",
|
||||
"q",
|
||||
"title",
|
||||
"s",
|
||||
FILTERS,
|
||||
]);
|
||||
expect(qk.plexCollections()).toEqual(["plex-collections"]);
|
||||
expect(qk.plexCollections("lib")).toEqual(["plex-collections", "lib"]);
|
||||
expect(qk.plexCollections("union", "term")).toEqual(["plex-collections", "union", "term"]);
|
||||
expect(qk.plexPlaylists()).toEqual(["plex-playlists"]);
|
||||
expect(qk.plexPlaylists("group", ["1", "2"])).toEqual(["plex-playlists", "group", ["1", "2"]]);
|
||||
expect(qk.plexFacets("both", "s", { genres: [] })).toEqual([
|
||||
"plex-facets",
|
||||
"both",
|
||||
"s",
|
||||
{ genres: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,15 +16,17 @@ export const qk = {
|
||||
tags: () => ["tags"] as const,
|
||||
|
||||
// --- feed / video ---
|
||||
feed: (filters?: FeedFilters) => (filters ? (["feed", filters] as const) : (["feed"] as const)),
|
||||
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
|
||||
// key" is the ONE rule across the factory. It also keeps a nullable segment intact: `q`/`id`
|
||||
// accept null because some call sites key on nullable state (`selectedId`, the search string),
|
||||
// and a NULL is a real key segment that must be preserved (`["playlist", null]`) rather than
|
||||
// silently collapsed to the prefix.
|
||||
feed: (filters?: FeedFilters) =>
|
||||
filters !== undefined ? (["feed", filters] as const) : (["feed"] as const),
|
||||
feedCount: (filters?: FeedFilters) =>
|
||||
filters ? (["feed-count", filters] as const) : (["feed-count"] as const),
|
||||
filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const),
|
||||
facets: (filters?: FeedFilters) =>
|
||||
filters ? (["facets", filters] as const) : (["facets"] as const),
|
||||
// `q`/`id` accept null (not just undefined) because the call sites key on nullable state
|
||||
// (`selectedId`, the search string): a NULL is a real key segment and must be preserved
|
||||
// (`["playlist", null]`), while a genuinely absent arg is the bare prefix key. The guard is
|
||||
// `!== undefined`, so only the no-arg call collapses to the prefix.
|
||||
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
|
||||
ytSearch: (q?: string | null, count?: number) =>
|
||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
|
||||
Reference in New Issue
Block a user