// THE SETTINGS SCHEMA: its shape, its boundaries, its migrations, and the // promise that a read never throws. // // Run with: node_modules/.bin/tsx --test common/lib/settingsSchema.test.ts // // SETTINGS SEAM, same arrangement as ./settingsWrite.test.ts: `getPaths()` // memoizes at module scope, so SETTINGS_FILE is set before anything imports // ./settings, and the module is imported dynamically below. import { mkdtempSync, writeFileSync } from "node:fs"; import { rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test, after } from "node:test"; import assert from "node:assert/strict"; const ROOT = mkdtempSync(path.join(os.tmpdir(), "settings-schema-")); process.env.TRANSCRIPTS_DIR = ROOT; process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); import type { SiteSettings } from "./settings"; import type { AppInstanceConfig } from "./transcriptionApps"; import type { Worker } from "./workers"; import type { AutoQueueSettings } from "./autoQueueTypes"; import type { ChannelPriority } from "./channelPriority"; import type { CookieMode } from "./cookiePolicy"; import type { DownloadFormatPreset, SourceVideoQuality, } from "../ytdlp/downloadFormat"; import type { StorageSettings } from "./storageLocations"; import type { ArchiveOrgFetchSettings } from "./archiveOrgTorrent"; import type { SeederSettings } from "./seederSettings"; import type { AttributionSettings, BackfillSettings, BuildPipelineSettings, DiarizationSettings, DigestSettings, PacingSettingsBlock, PublishSettings, ReportDebouncePreset, SavedVideoBackupSettings, SocialLink, SocialSettings, SyncSchedulerSettings, } from "./settingsSchema"; const S = await import("./settings"); const { siteSettingsSchema, defaultSiteSettings, defaults, getSettings } = S; after(() => rm(ROOT, { recursive: true, force: true })); // ── THE SHAPE, PINNED ──────────────────────────────────────────────────────── // // `SiteSettings` is `z.infer` now. This is the type // as it was hand-written before slice 4a, spelled out literally, so a schema // edit that changes what 200 importers see is a tsc error here — not a surprise // somewhere else. // // ONE DELIBERATE DIFFERENCE: `archiveStorage` was `archiveStorage?: {…}`. It was // never absent at runtime (defaults(), getSettings and writeSettings all // emitted it), and zod 4 cannot express "optional key that is always emitted", // so it is required now. Nothing in the repo relied on the `?`. type PreSchemaSiteSettings = { adminTitle: string; maxTranscriptPageBytes: number; transcriptionApp: string; transcriptionApps: Record; workers: Worker[]; cookiesFromBrowser: string; cookieMode: CookieMode; // Release 16 slice XL — the one key it adds. social: SocialSettings; sleepBetweenDownloadsSeconds: number; // Release 17 slice RL — the adaptive pace and the hold. pacing: PacingSettingsBlock; downloadFormat: DownloadFormatPreset; // The source-video quality a full persist keeps. sourceVideoQuality: SourceVideoQuality; minFreeDiskGB: number; resumeMarginGB: number; parallelTranscriptions: number; inlineTranscribeOnFallback: boolean; skipLiveDownloads: boolean; verifyAvailabilityBeforeClean: boolean; buildArchives: boolean; archiveStorage: { bucket: string; publicBaseUrl: string }; reportDebouncePreset: ReportDebouncePreset; autoRefreshIntervalSeconds: number; syncScheduler: SyncSchedulerSettings; autoQueue: AutoQueueSettings; channelPriority: ChannelPriority; socialLinks: SocialLink[]; homepageUrl: string; savedVideoBackup: SavedVideoBackupSettings; storage: StorageSettings; buildPipeline: BuildPipelineSettings; digest: DigestSettings; diarization: DiarizationSettings; backfill: BackfillSettings; attribution: AttributionSettings; // How archive.org files are fetched: BitTorrent with seeding, else direct. archiveOrg: ArchiveOrgFetchSettings; // The publish lane (release 18). publish: PublishSettings; // The home seeder of last resort (release 21 D4b). seeder: SeederSettings; }; // Bracketed so the conditional does not distribute (see commit 8c43231). type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const shapeUnchanged: Same = true; test("SiteSettings keeps its 37 fields, in file order", () => { assert.equal(shapeUnchanged, true); assert.deepEqual(Object.keys(siteSettingsSchema.shape), [ "adminTitle", "maxTranscriptPageBytes", "transcriptionApp", "transcriptionApps", "workers", "cookiesFromBrowser", "cookieMode", "social", "sleepBetweenDownloadsSeconds", "pacing", "downloadFormat", "sourceVideoQuality", "minFreeDiskGB", "resumeMarginGB", "parallelTranscriptions", "inlineTranscribeOnFallback", "skipLiveDownloads", "verifyAvailabilityBeforeClean", "buildArchives", "archiveStorage", "reportDebouncePreset", "autoRefreshIntervalSeconds", "syncScheduler", "autoQueue", "channelPriority", "socialLinks", "homepageUrl", "savedVideoBackup", "storage", "buildPipeline", "digest", "diarization", "backfill", "attribution", "archiveOrg", "publish", "seeder", ]); // A parsed object carries every key, in that order — writeSettings writes // exactly this, so the order is the on-disk order. assert.deepEqual( Object.keys(defaults()), Object.keys(siteSettingsSchema.shape), ); }); test("every field has a description (SETTINGS.md is generated from them)", () => { for (const [key, field] of Object.entries(siteSettingsSchema.shape)) { assert.ok( typeof field.description === "string" && field.description.length > 20, `${key} has no .describe()`, ); } }); test("defaults() and defaultSiteSettings() are the schema's answer for an empty file", () => { assert.deepEqual(defaultSiteSettings(), siteSettingsSchema.parse({})); assert.deepEqual(defaults(), defaultSiteSettings()); const d = defaults(); assert.equal(d.adminTitle, S.DEFAULT_ADMIN_TITLE); assert.equal(d.maxTranscriptPageBytes, S.TRANSCRIPT_PAGE_DEFAULT_BYTES); assert.deepEqual(d.workers, []); assert.deepEqual(d.archiveStorage, { bucket: "", publicBaseUrl: "" }); assert.equal(d.skipLiveDownloads, true); assert.equal(d.inlineTranscribeOnFallback, false); // The X login source is resolved at read time, so the default stores none. assert.deepEqual(d.social, { x: {} }); }); test("social.x.cookieSource keeps a known source and drops anything else", () => { const parse = (social: unknown) => siteSettingsSchema.parse({ social }).social; assert.deepEqual(parse({ x: { cookieSource: "browser" } }), { x: { cookieSource: "browser" } }); assert.deepEqual(parse({ x: { cookieSource: "profile" } }), { x: { cookieSource: "profile" } }); // An unknown source reads as absent — the read-time default — never a throw. assert.deepEqual(parse({ x: { cookieSource: "chrome" } }), { x: {} }); assert.deepEqual(parse({ x: { cookieSource: "browser", extra: 1 }, bsky: {} }), { x: { cookieSource: "browser" }, }); for (const bad of [null, "browser", [], 3, { x: "browser" }, { x: [] }]) { assert.deepEqual(parse(bad), { x: {} }, JSON.stringify(bad)); } }); // ── THE CLAMP BOUNDARIES ───────────────────────────────────────────────────── type Case = [input: unknown, expected: number]; function checkField( key: keyof SiteSettings, cases: Case[], ): void { for (const [input, expected] of cases) { const got = siteSettingsSchema.parse({ [key]: input })[key]; assert.equal(got, expected, `${key}: ${JSON.stringify(input)} -> ${got}`); } } // min / max / NaN / string / null / absent, for each clamped top-level field. // THE RETIRED `buildPipeline.mode` (release 11, follow-up O6c). It was a label // nothing read. A file that still has it — the operator's says "basic" — must // load, keep the three real fields, and carry no `mode` onward. test("a buildPipeline that still carries the retired mode loads, and the mode is gone", () => { for (const mode of ["basic", "docker", "nonsense", 3]) { const clean = S.sanitizeBuildPipeline({ mode, maxParallelBuilds: 3, dockerImage: " my-image ", dockerfile: "Dockerfile.build", }); assert.deepEqual(clean, { maxParallelBuilds: 3, dockerImage: "my-image", dockerfile: "Dockerfile.build", }); assert.equal("mode" in clean, false, String(mode)); } // Through the whole-file parse, as every process reads it. const parsed = siteSettingsSchema.parse({ buildPipeline: { mode: "basic", maxParallelBuilds: 5 } }); assert.equal("mode" in parsed.buildPipeline, false); assert.equal(parsed.buildPipeline.maxParallelBuilds, 5); assert.equal("mode" in defaults().buildPipeline, false); }); test("maxTranscriptPageBytes clamps into [256 KiB, 20 MiB]", () => { checkField("maxTranscriptPageBytes", [ [0, S.TRANSCRIPT_PAGE_MIN_BYTES], [S.TRANSCRIPT_PAGE_MIN_BYTES - 1, S.TRANSCRIPT_PAGE_MIN_BYTES], [S.TRANSCRIPT_PAGE_MIN_BYTES, S.TRANSCRIPT_PAGE_MIN_BYTES], [S.TRANSCRIPT_PAGE_HARD_CAP_BYTES, S.TRANSCRIPT_PAGE_HARD_CAP_BYTES], [S.TRANSCRIPT_PAGE_HARD_CAP_BYTES + 1, S.TRANSCRIPT_PAGE_HARD_CAP_BYTES], [1_000_000.7, 1_000_000], [NaN, S.TRANSCRIPT_PAGE_DEFAULT_BYTES], ["9000000", S.TRANSCRIPT_PAGE_DEFAULT_BYTES], [null, S.TRANSCRIPT_PAGE_DEFAULT_BYTES], [undefined, S.TRANSCRIPT_PAGE_DEFAULT_BYTES], ]); }); test("sleepBetweenDownloadsSeconds: 0 is off, capped at the max", () => { checkField("sleepBetweenDownloadsSeconds", [ [-1, 0], [0, 0], [S.SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS, S.SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS], [S.SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS + 1, S.SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS], [2.9, 2], [NaN, S.SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS], ["5", S.SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS], [null, S.SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS], ]); }); test("minFreeDiskGB: 0 disables the gate, capped at the max", () => { checkField("minFreeDiskGB", [ [-5, 0], [0, 0], [S.MIN_FREE_DISK_GB_MAX, S.MIN_FREE_DISK_GB_MAX], [S.MIN_FREE_DISK_GB_MAX + 1, S.MIN_FREE_DISK_GB_MAX], [NaN, S.MIN_FREE_DISK_GB_DEFAULT], ["0", S.MIN_FREE_DISK_GB_DEFAULT], [null, S.MIN_FREE_DISK_GB_DEFAULT], ]); }); test("resumeMarginGB: 0 disables the hysteresis, capped at the max", () => { checkField("resumeMarginGB", [ [-1, 0], [0, 0], [S.RESUME_MARGIN_GB_MAX, S.RESUME_MARGIN_GB_MAX], [S.RESUME_MARGIN_GB_MAX + 1, S.RESUME_MARGIN_GB_MAX], [NaN, S.RESUME_MARGIN_GB_DEFAULT], ["2", S.RESUME_MARGIN_GB_DEFAULT], [null, S.RESUME_MARGIN_GB_DEFAULT], ]); }); test("parallelTranscriptions: floored at 1, capped at the max", () => { checkField("parallelTranscriptions", [ [0, 1], [-3, 1], [1, 1], [S.PARALLEL_TRANSCRIPTIONS_MAX, S.PARALLEL_TRANSCRIPTIONS_MAX], [S.PARALLEL_TRANSCRIPTIONS_MAX + 1, S.PARALLEL_TRANSCRIPTIONS_MAX], [NaN, S.PARALLEL_TRANSCRIPTIONS_DEFAULT], ["4", S.PARALLEL_TRANSCRIPTIONS_DEFAULT], [null, S.PARALLEL_TRANSCRIPTIONS_DEFAULT], ]); }); test("autoRefreshIntervalSeconds: 0 survives as off, the rest clamps", () => { checkField("autoRefreshIntervalSeconds", [ [0, 0], [-10, 0], [0.5, 0], [S.AUTO_REFRESH_INTERVAL_MIN_SECONDS, S.AUTO_REFRESH_INTERVAL_MIN_SECONDS], [S.AUTO_REFRESH_INTERVAL_MAX_SECONDS, S.AUTO_REFRESH_INTERVAL_MAX_SECONDS], [S.AUTO_REFRESH_INTERVAL_MAX_SECONDS + 1, S.AUTO_REFRESH_INTERVAL_MAX_SECONDS], [NaN, S.AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS], ["5", S.AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS], [null, S.AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS], ]); }); test("syncScheduler.heartbeatSeconds: 0 is off, positive clamps into [MIN, MAX]", () => { const hb = (v: unknown) => siteSettingsSchema.parse({ syncScheduler: { heartbeatSeconds: v } }) .syncScheduler.heartbeatSeconds; assert.equal(hb(0), 0); assert.equal(hb(-1), 0); assert.equal(hb(1), S.SYNC_HEARTBEAT_MIN_SECONDS); assert.equal(hb(S.SYNC_HEARTBEAT_MAX_SECONDS + 1), S.SYNC_HEARTBEAT_MAX_SECONDS); assert.equal(hb(NaN), S.SYNC_HEARTBEAT_DEFAULT_SECONDS); assert.equal(hb("60"), S.SYNC_HEARTBEAT_DEFAULT_SECONDS); assert.equal(hb(null), S.SYNC_HEARTBEAT_DEFAULT_SECONDS); }); test("sync cadences that allow zero keep it; the ones that do not floor at 1", () => { const s = siteSettingsSchema.parse({ syncScheduler: { fullSweepIntervalMinutes: 0, fullSweepConfirmMaxSuspects: 0, fullSweepShrinkGuardPercent: 0, defaultIntervalMinutes: 0, maxConcurrentSyncs: 0, quietHoursStart: 24, quietHoursEnd: 6, }, }).syncScheduler; assert.equal(s.fullSweepIntervalMinutes, 0); assert.equal(s.fullSweepConfirmMaxSuspects, 0); assert.equal(s.fullSweepShrinkGuardPercent, 0); assert.equal(s.defaultIntervalMinutes, 1); assert.equal(s.maxConcurrentSyncs, 1); // An out-of-range hour clears BOTH ends of the window. assert.equal(s.quietHoursStart, null); assert.equal(s.quietHoursEnd, null); }); test("enum fields fall back to their default on junk", () => { const s = siteSettingsSchema.parse({ cookieMode: "sometimes", downloadFormat: "best", sourceVideoQuality: "1080p", reportDebouncePreset: "instant", transcriptionApp: "no-such-app", }); const d = defaults(); assert.equal(s.cookieMode, d.cookieMode); assert.equal(s.downloadFormat, "auto"); assert.equal(s.sourceVideoQuality, "original"); assert.equal(d.sourceVideoQuality, "original"); assert.equal( siteSettingsSchema.parse({ sourceVideoQuality: "video_720" }).sourceVideoQuality, "video_720", ); assert.equal(s.reportDebouncePreset, d.reportDebouncePreset); assert.equal(s.transcriptionApp, d.transcriptionApp); }); test("booleans: only an explicit value moves them off their default", () => { const s = siteSettingsSchema.parse({ inlineTranscribeOnFallback: "yes", skipLiveDownloads: "no", verifyAvailabilityBeforeClean: 0, buildArchives: false, }); assert.equal(s.inlineTranscribeOnFallback, false); assert.equal(s.skipLiveDownloads, true); assert.equal(s.verifyAvailabilityBeforeClean, true); assert.equal(s.buildArchives, false); }); test("an unknown key is stripped, at the top level and inside a block", () => { const s = siteSettingsSchema.parse({ siteTitle: "pre-multi-site", transcriptionsPaused: true, backfill: { enabled: true, concurrency: 2 }, }) as Record; assert.equal("siteTitle" in s, false); assert.equal("transcriptionsPaused" in s, false); assert.deepEqual(s.backfill, { concurrency: 2, allowRedownload: false }); }); // ── THE LANE GATE AND THE ZERO HOLD ────────────────────────────────────────── test("held defaults to [false, false, false, true] — backfill ships held", () => { const aq = defaults().autoQueue; assert.deepEqual( [aq.transcription.held, aq.download.held, aq.digest.held, aq.backfill.held], [false, false, false, true], ); // And through a file that names the lanes but no gate. const bare = siteSettingsSchema.parse({ autoQueue: { transcription: {}, download: {}, digest: {}, backfill: {} }, }).autoQueue; assert.deepEqual( [bare.transcription.held, bare.download.held, bare.digest.held, bare.backfill.held], [false, false, false, true], ); }); // ── getSettings: THE RAW MIGRATIONS AND THE NEVER-THROW ────────────────────── function withFile(contents: string): SiteSettings { writeFileSync(process.env.SETTINGS_FILE!, contents); return getSettings(); } test("getSettings never throws on a file that is not a settings object", () => { // THE EMPTY FILE is the reference, not bare defaults(): an empty file still // runs the absence-keyed migrations (workers synthesized, the two sweep lanes // built by migrateSweepsToLanes), and a non-object file must read as exactly // that. Before slice 4a, a file containing `null` threw. const empty = withFile("{}"); assert.equal(empty.workers.length, S.PARALLEL_TRANSCRIPTIONS_DEFAULT); for (const contents of ["{", "null", "[]", "3", "", '"text"']) { let got: SiteSettings | undefined; assert.doesNotThrow(() => { got = withFile(contents); }, `contents ${JSON.stringify(contents)}`); assert.deepEqual(got, empty, `contents ${JSON.stringify(contents)}`); } }); test("workers are synthesized ONLY when the key is absent", () => { const absent = withFile(JSON.stringify({ parallelTranscriptions: 3 })); assert.equal(absent.workers.length, 3); assert.ok(absent.workers.every((w) => w.enabled && w.kind === "local")); const empty = withFile(JSON.stringify({ workers: [] })); assert.deepEqual(empty.workers, []); }); test("legacy transcribe* fields migrate ONLY when transcriptionApp is absent", () => { const legacy = { transcribeBin: "/opt/chough", transcribeModel: "/models/x.bin", }; const migrated = withFile(JSON.stringify(legacy)); assert.equal(migrated.transcriptionApp, "chough"); assert.equal(migrated.transcriptionApps.chough?.bin, "/opt/chough"); assert.equal(migrated.workers[0].appId, "chough"); const named = withFile( JSON.stringify({ ...legacy, transcriptionApp: "whisper-cpp" }), ); assert.equal(named.transcriptionApp, "whisper-cpp"); assert.equal(named.transcriptionApps.chough, undefined); }); test("storage.mediaRoot migrates ONLY when locations is absent", () => { const migrated = withFile( JSON.stringify({ storage: { mediaRoot: "/mnt/cold" } }), ); assert.deepEqual(migrated.storage.locations.map((l) => l.root), ["/mnt/cold"]); assert.equal(migrated.storage.defaultLocationId, "default"); const listed = withFile( JSON.stringify({ storage: { mediaRoot: "/mnt/cold", locations: [] } }), ); assert.deepEqual(listed.storage.locations, []); const none = withFile(JSON.stringify({})); assert.deepEqual(none.storage, { locations: [], defaultLocationId: "" }); }); test("the retired sweep fields migrate onto a lane ONLY when the lane is absent", () => { const migrated = withFile( JSON.stringify({ digest: { sweepEnabled: true, sweepChannels: ["a"] } }), ); assert.equal(migrated.autoQueue.digest.enabled, true); // Absence was the trigger, not the default: a file that spells the lane // keeps its own answer even though the sweep flag says otherwise. const spelled = withFile( JSON.stringify({ digest: { sweepEnabled: true }, autoQueue: { digest: { enabled: false } }, }), ); assert.equal(spelled.autoQueue.digest.enabled, false); // No sweep flag, no lane: the migration never enables anything. assert.equal(withFile("{}").autoQueue.digest.enabled, false); assert.equal(withFile("{}").autoQueue.backfill.enabled, false); }); // RELEASE 18: the publish lane's block. test("publish: defaults, and every field sanitized", () => { const { sanitizePublish, defaultPublish } = S; const d = defaults().publish; assert.deepEqual(d, { enabled: false, held: false, checkEveryMinutes: 10, refreshEveryMinutes: 360, quietHours: null, runner: "local", previewBranch: "preview", hub: "off", homepage: "off", }); assert.deepEqual(defaultPublish(), d); for (const raw of [undefined, null, 3, "x", []]) assert.deepEqual(sanitizePublish(raw), d); const good = sanitizePublish({ enabled: true, held: true, checkEveryMinutes: 5, refreshEveryMinutes: 0, quietHours: { start: 22, end: 6 }, runner: "docker", previewBranch: "r18", hub: "preview", homepage: "production", }); assert.deepEqual(good, { enabled: true, held: true, checkEveryMinutes: 5, refreshEveryMinutes: 0, quietHours: { start: 22, end: 6 }, runner: "docker", previewBranch: "r18", hub: "preview", homepage: "production", }); const bad = sanitizePublish({ enabled: "yes", held: 1, checkEveryMinutes: 0, refreshEveryMinutes: 10 ** 9, quietHours: { start: 3, end: 3 }, runner: "kubernetes", previewBranch: "main", hub: "always", homepage: null, }); assert.deepEqual(bad, { ...d, checkEveryMinutes: 1, refreshEveryMinutes: 43200, }); assert.equal(sanitizePublish({ checkEveryMinutes: 99999 }).checkEveryMinutes, 1440); assert.equal(sanitizePublish({ quietHours: { start: 24, end: 6 } }).quietHours, null); assert.equal(sanitizePublish({ quietHours: { start: 1 } }).quietHours, null); assert.equal(sanitizePublish({ previewBranch: "Has Spaces" }).previewBranch, "preview"); assert.equal(sanitizePublish({ previewBranch: " r18 " }).previewBranch, "r18"); }); test("publish: an unknown key inside the block is dropped, and the block round-trips the schema", () => { const parsed = siteSettingsSchema.parse({ publish: { enabled: true, held: true, extra: 1, previewBranch: "smoke" }, }); assert.equal(parsed.publish.enabled, true); assert.equal(parsed.publish.held, true); assert.equal(parsed.publish.previewBranch, "smoke"); assert.equal("extra" in parsed.publish, false); assert.deepEqual(siteSettingsSchema.parse(JSON.parse(JSON.stringify(parsed))).publish, parsed.publish); });