Files
siftlode/frontend/src/lib/api/facade.test.ts
T
peter 5883ea509d feat: Settings → Diagnostics, a consented log level and the tab's own log
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a
new Settings tab, after a consent box that says what is recorded, that the server keeps it and for
how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent.

- users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which
  computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force.
- The server logs an elevated user's requests at that level and stores every line of them
  (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation
  is active). Teljes adds the redacted query string to the request line.
- POST /api/diagnostics/events stores the browser's log as source=web rows: account from the
  session, body capped while streaming, events/attrs capped, re-redacted, events under the
  consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed.
- lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in
  batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its
  own timer), on withdrawal and on 409.
- Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user
  text, input values or a link's query), visibility/bfcache/resize, filter changes
  (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are
  warnings at every level). Offline/online at the base level.
- "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy
  the filtered events as JSON lines, clear.
- Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to
  the same sections); the policy now describes the server logs and diagnostic logging, self-service
  deletion and password sign-in.

Fixed on the way:
- A sync auth dependency ran logs.set_user on a copied context, so the account never reached a
  handler's own log lines (measured); a per-request mutable scope carries it now.
- The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is
  a scrolling row above the content.
- PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
2026-09-17 02:42:12 +02:00

95 lines
4.1 KiB
TypeScript

import { describe, it, expect, beforeAll } from "vitest";
// The R7.5 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<string, unknown>;
// 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")],
["messagingApi", () => import("./messaging")],
["downloadsApi", () => import("./downloads")],
["diagnosticsApi", () => import("./diagnostics")],
] as const;
let api: Slice;
const slices: Record<string, Slice> = {};
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<string, string>();
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<string, Slice>;
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<string, string>();
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<string>();
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);
});
});