import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; // Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these // up with the raw id as the fallback — `t(\`config.fields.${key}.label\`, key)` — so a missing key never // fails loudly: the admin Configuration page showed `maintenance`, `tmdb`, `tmdb_api_key`, // `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN // and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads // the registries straight from the backend sources: // - `sysconfig.SPECS` → `config.groups.`, `config.fields..label` + `.hint` // - `scheduler.JOB_INTERVALS` → `scheduler.jobs.` + `scheduler.jobDesc.` // - the `audit` action enum → `auditActions.`; the `quota` action enum → `quotaActions.` // and the other way round: a name left behind for an id the backend dropped is dead weight that reads // as a live setting to the next person (`plex_sync_interval_min` moved to the scheduler long ago). // Progress phases are not covered: jobs pass most of them as variables, not literals a scan can see. const backend = (file: string) => readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8"); const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record< string, { default: Record } >; function flatten(obj: Record, prefix: string, out: Set): Set { for (const [k, v] of Object.entries(obj)) { const key = `${prefix}.${k}`; if (v && typeof v === "object" && !Array.isArray(v)) flatten(v as Record, key, out); else out.add(key); } return out; } const keysOf = (lang: string): Set => { const out = new Set(); for (const [path, mod] of Object.entries(mods)) { const m = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/); if (m?.[1] === lang) flatten(mod.default, m[2]!, out); } return out; }; const specs = [ ...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"\w+",\s*"(\w+)"/g), ].map((m) => ({ key: m[1]!, group: m[2]! })); const jobs = [ ...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "") .split("}")[0]! .matchAll(/"(\w+)": settings\./g), ].map((m) => m[1]!); const enumValues = (file: string) => [...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!); const auditActions = enumValues("audit.py"); const quotaActions = enumValues("quota.py"); /** The ids under `prefix` a locale names, `prefix.` or `prefix..`. */ const idsUnder = (keys: Set, prefix: string) => new Set( [...keys] .filter((k) => k.startsWith(`${prefix}.`)) .map((k) => k.split(".")[prefix.split(".").length]!), ); describe("backend ids have their names in both languages", () => { it("read the registries (a scan that matches nothing is green over nothing)", () => { expect(specs.map((s) => s.key)).toEqual(expect.arrayContaining(["smtp_host", "tmdb_language"])); expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"])); expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"])); expect(quotaActions.length).toBeGreaterThan(0); }); for (const lang of ["en", "hu"]) { const keys = keysOf(lang); const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k)); it(`${lang}: every config key and group`, () => { expect( missing([ ...new Set(specs.map((s) => `config.groups.${s.group}`)), ...specs.flatMap((s) => [`config.fields.${s.key}.label`, `config.fields.${s.key}.hint`]), ]), ).toEqual([]); const known = new Set(specs.map((s) => s.key)); expect([...idsUnder(keys, "config.fields")].filter((k) => !known.has(k))).toEqual([]); const groups = new Set(specs.map((s) => s.group)); expect([...idsUnder(keys, "config.groups")].filter((g) => !groups.has(g))).toEqual([]); }); it(`${lang}: every scheduler job`, () => { expect( missing(jobs.flatMap((j) => [`scheduler.jobs.${j}`, `scheduler.jobDesc.${j}`])), ).toEqual([]); for (const prefix of ["scheduler.jobs", "scheduler.jobDesc"]) expect([...idsUnder(keys, prefix)].filter((j) => !jobs.includes(j))).toEqual([]); }); it(`${lang}: every audit and quota action`, () => { expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]); expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]); }); } });