import { describe, it, expect, beforeAll } from "vitest"; // The façade guard: lib/api.ts is a spread-only façade over per-domain slice modules. These tests // mechanise the two invariants that make that structure safe — so a future edit that reintroduces // the interleave (a method dropped into two slices → a silent last-wins spread override) or bloats // the barrel (a method defined inline instead of in a domain module) fails CI instead of shipping. // // Scope, on purpose: this covers RUNTIME method-key collisions — the only *silent* failure mode, // since tsc does not flag overlapping spread keys. It deliberately does NOT cover exported-TYPE-name // clashes across slices (two slices exporting the same interface name): types are erased at runtime // so they're invisible here, and that case is already build-caught — the barrel's `export type *` // makes the ambiguous name a downstream tsc "no exported member" error, so nothing ships. Left to // tsc rather than re-checked by a fragile source parse. type Slice = Record; // Names line up with the modules below; kept as a list so the wiring stays a single source. const SLICE_MODULES = [ ["accountApi", () => import("./account")], ["authApi", () => import("./auth")], ["setupApi", () => import("./setup")], ["feedApi", () => import("./feed")], ["discoverApi", () => import("./discover")], ["channelsApi", () => import("./channels")], ["syncApi", () => import("./sync")], ["playlistsApi", () => import("./playlists")], ["tagsApi", () => import("./tags")], ["notificationsApi", () => import("./notifications")], ["savedViewsApi", () => import("./savedViews")], ["quotaApi", () => import("./quota")], ["adminApi", () => import("./admin")], ["plexApi", () => import("./plex")], ["downloadsApi", () => import("./downloads")], ["diagnosticsApi", () => import("./diagnostics")], ["debugSessionsApi", () => import("./diagnostics")], ] as const; let api: Slice; const slices: Record = {}; beforeAll(async () => { // The runtime api graph pulls i18n at module-load, which reads localStorage/navigator. Under // vitest's node env (no jsdom — see vitest.config.ts) stub the minimum so the import succeeds. if (typeof globalThis.localStorage === "undefined") { const store = new Map(); globalThis.localStorage = { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, String(v)), removeItem: (k: string) => void store.delete(k), clear: () => store.clear(), key: (i: number) => [...store.keys()][i] ?? null, get length() { return store.size; }, } as Storage; } if (typeof globalThis.navigator === "undefined") { globalThis.navigator = { language: "en" } as Navigator; } if (typeof globalThis.document === "undefined") { globalThis.document = { documentElement: {} } as Document; } for (const [name, load] of SLICE_MODULES) { const mod = (await load()) as Record; const slice = mod[name]; if (!slice) throw new Error(`slice module did not export ${name}`); slices[name] = slice; } api = ((await import("../api")) as { api: Slice }).api; }); describe("api façade is a collision-free spread of domain slices", () => { it("defines no method name in more than one slice (object spread would silently drop one)", () => { const owner = new Map(); const collisions: string[] = []; for (const [sliceName, slice] of Object.entries(slices)) { for (const key of Object.keys(slice)) { const prev = owner.get(key); if (prev) collisions.push(`"${key}" is defined in both ${prev} and ${sliceName}`); else owner.set(key, sliceName); } } expect(collisions).toEqual([]); }); it("exposes exactly the union of the slices — nothing added inline in the barrel, nothing lost", () => { const union = new Set(); for (const slice of Object.values(slices)) { for (const key of Object.keys(slice)) union.add(key); } expect(new Set(Object.keys(api))).toEqual(union); }); });