A failed Facebook download read "Download failed": yt-dlp words the login wall (18+ reel, closed group) as "Cannot parse data", which no category matched. Measured on dev with the owner's cookie before writing a rule — the same text is the login wall without a cookie and a refused cookie with one. - errors.diagnose(msg, url, cookie_attached) tries a host-scoped catalog first (all-of markers, cookie state, link fragment) and falls back to the category. First entries: facebook.login_wall, facebook.cookie_rejected, facebook.group_post_video. The module doc is the recipe for the next entry. - download_jobs.error_detail (0084) + backfill of the stored Facebook parse failures that were unknown. - The pipeline's failure branch passes the link and whether the cookie went with it; resume and redownload clear code and detail (redownload never cleared error_code). - A job meeting an already-failed asset got the text but no code, so it read "Download failed" where the first job said why. It is diagnosed again, with the cookie state from credentials.is_authed_asset (now shared with the cookie gate); the redacted network text classifies back to network. - FE: failCopyKeys gives the row and the failure window one lookup — catalog copy, else the category. Copy in EN and HU; a contract test pins every backend entry to both locales.
148 lines
6.7 KiB
TypeScript
148 lines
6.7 KiB
TypeScript
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.<group>`, `config.fields.<key>.label` + `.hint`
|
|
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
|
|
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
|
|
// 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<string, unknown> }
|
|
>;
|
|
|
|
function flatten(obj: Record<string, unknown>, prefix: string, out: Set<string>): Set<string> {
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
const key = `${prefix}.${k}`;
|
|
if (v && typeof v === "object" && !Array.isArray(v))
|
|
flatten(v as Record<string, unknown>, key, out);
|
|
else out.add(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const keysOf = (lang: string): Set<string> => {
|
|
const out = new Set<string>();
|
|
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.<reason>`. 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.<detail>`.
|
|
// 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.<id>` or `prefix.<id>.<anything>`. */
|
|
const idsUnder = (keys: Set<string>, 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(
|
|
[],
|
|
);
|
|
});
|
|
}
|
|
});
|