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 admin actions' refusal reasons → `errors.admin.`. The codes are read from // `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict // ruff wraps over several lines still matches. const adminCodes = [ ...( readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match( /ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/, )?.[1] ?? "" ).matchAll(/"(\w+)"/g), ].map((m) => m[1]!); const adminRefusals = [ ...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g), ].filter((m) => adminCodes.includes(m[1]!)); const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))]; // The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.`. // A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part // rather than splitting on dots. const failDetails = [ ...new Set([...backend("downloads/errors.py").matchAll(/detail="([\w.]+)"/g)].map((m) => m[1]!)), ]; const DETAIL = "downloads.errors.fail.catalog."; /** 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); expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"])); expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); // Every code the frontend localizes is raised somewhere the scan can see. expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]); }); 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([]); }); it(`${lang}: every download failure-catalog entry`, () => { expect( missing(failDetails.flatMap((d) => [`${DETAIL}${d}.short`, `${DETAIL}${d}.body`])), ).toEqual([]); const named = new Set( [...keys] .filter((k) => k.startsWith(DETAIL)) .map((k) => k.slice(DETAIL.length).replace(/\.(short|body)$/, "")), ); expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]); }); it(`${lang}: every admin refusal reason`, () => { expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]); expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual( [], ); }); } });