import { test } from "node:test"; import assert from "node:assert/strict"; import { parseChannelConfig, type ChannelConfig } from "../lib/channelConfig"; import { sanitizeChannelPriority, type ChannelPriority, } from "../lib/channelPriority"; import { defaultSyncScheduler } from "../lib/settings"; import type { SyncSchedulerSettings } from "../lib/settings"; import { emptySchedulerState } from "./syncSchedulerState"; import { buildScheduleView, selectDueChannels, type ChannelEntry, } from "./syncScheduler"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test jobs/syncScheduler.test.ts // // THE SYNC HALF OF plans/channel-priority.md (S2). `selectDueChannels` and // `buildScheduleView` are the two places the model reaches the cron sync, and // the pair must agree: the console cannot show a channel as eligible that the // scheduler will not schedule. // // Everything here is pure — no server, no settings file. The model is built // through `sanitizeChannelPriority` rather than by hand so a test can only // assert over a document the writer could actually produce. const HOUR = 60 * 60_000; const DAY = 24 * HOUR; const NOW = Date.parse("2026-09-11T12:00:00.000Z"); function scheduler( over: Partial = {}, ): SyncSchedulerSettings { return { ...defaultSyncScheduler(), enabled: true, defaultIntervalMinutes: 60, quietHoursStart: null, quietHoursEnd: null, ...over, }; } // A channel that IS due: last synced `overdueMs` past its 60-minute interval. function channel( slug: string, overdueMs: number, over: Partial = {}, ): ChannelEntry { return { slug, config: { handling: "youtube", name: slug, url: `https://www.youtube.com/@${slug}/videos`, lastSyncedAt: new Date(NOW - HOUR - overdueMs).toISOString(), ...over, }, }; } function model(doc: unknown): ChannelPriority { return sanitizeChannelPriority(doc); } const NO_MODEL = sanitizeChannelPriority(undefined); function due( channels: ChannelEntry[], priority: ChannelPriority = NO_MODEL, focusSlugs: string[] = [], ) { return selectDueChannels({ channels, scheduler: scheduler(), state: emptySchedulerState(), activeSlugs: new Set(), now: NOW, priority, focusSlugs, }); } test("with no priority document the order is most-overdue-first, as before", () => { const r = due([ channel("a", 1 * 60_000), channel("b", 1 * DAY), channel("c", 1 * HOUR), ]); assert.deepEqual(r.due, ["b", "c", "a"]); assert.deepEqual(r.skipped, []); }); test("a focus channel one minute overdue outranks a low channel a day overdue", () => { // Plan test 6. The concurrency cap slices this list from the front, so the // ordering IS the policy: whoever is first gets the tick's slots. const r = due( [channel("low-one", 1 * DAY), channel("focused", 1 * 60_000)], model({ channels: { "low-one": { tier: "low" } } }), ["focused"], ); assert.deepEqual(r.due, ["focused", "low-one"]); }); test("tier orders before overdue, and rank orders inside a tier", () => { const r = due( [ channel("low-stale", 10 * DAY), channel("normal-fresh", 1 * 60_000), channel("focus-rank-2", 1 * 60_000), channel("focus-rank-1", 1 * 60_000), channel("focus-unranked", 5 * DAY), ], model({ channels: { "low-stale": { tier: "low" }, "focus-rank-1": { tier: "normal", rank: 1 }, "focus-rank-2": { tier: "normal", rank: 2 }, }, }), ["focus-rank-1", "focus-rank-2", "focus-unranked"], ); // focus (rank 1, rank 2, then unranked however stale) -> normal -> low. assert.deepEqual(r.due, [ "focus-rank-1", "focus-rank-2", "focus-unranked", "normal-fresh", "low-stale", ]); }); test("most-overdue-first survives WITHIN a tier", () => { const r = due( [channel("n-fresh", 1 * 60_000), channel("n-stale", 1 * DAY)], model({ channels: {} }), ); assert.deepEqual(r.due, ["n-stale", "n-fresh"]); }); test("ties all the way down keep the input order", () => { // `listChannelConfigs` hands this function slug order and the sort is stable, // so the pre-model tiebreak (alphabetical) is preserved by not touching it. const r = due([channel("b", 1 * HOUR), channel("a", 1 * HOUR)]); assert.deepEqual(r.due, ["b", "a"]); }); test("a sync override of paused skips while the base tier stays normal", () => { // The migrated `excludeFromSync` shape: stop syncing, keep every other lane. const priority = model({ channels: { parked: { tier: "normal", overrides: { sync: "paused" } } }, }); assert.equal(priority.channels.parked.tier, "normal"); const r = due([channel("parked", 1 * DAY), channel("live", 1 * HOUR)], priority); assert.deepEqual(r.due, ["live"]); // Silently, like every other configuration gate in the loop — the run log is // for holds worth reading (backoff, already running), not for "this channel // does not auto-sync". assert.deepEqual(r.skipped, []); }); test("a base-paused channel with a sync override of normal still syncs", () => { // The "sync only" preset (the operator's omnibased case): paused everywhere // the auto lanes look, still keeping its playlist and metadata current. const priority = model({ channels: { omnibased: { tier: "paused", overrides: { sync: "normal" } } }, }); assert.equal(priority.channels.omnibased.overrides?.sync, "normal"); const r = due([channel("omnibased", 1 * HOUR)], priority); assert.deepEqual(r.due, ["omnibased"]); }); test("a base-paused channel with no sync override never becomes due", () => { const r = due( [channel("off", 10 * DAY)], model({ channels: { off: { tier: "paused" } } }), ); assert.deepEqual(r.due, []); assert.deepEqual(r.skipped, []); }); test("focus does not rescue a channel paused for sync", () => { // Focus wins over the stored tier; paused wins over focus, per operation. const r = due( [channel("held", 1 * DAY), channel("other", 1 * HOUR)], model({ channels: { held: { tier: "paused" } } }), ["held"], ); assert.deepEqual(r.due, ["other"]); }); test("an un-migrated config carries nothing the scheduler can skip on", () => { // `excludeFromSync` is DELETED (S5), and `parseChannelConfig` is allow-list // style, so a config.json that still spells the key parses to a channel that // says nothing about sync at all. That is the correct reading: the migration // is what turns the flag into `overrides: {sync:"paused"}`, and until it has // run the channel syncs — which is what it did before the flag was invented. const parsed = parseChannelConfig({ handling: "youtube", url: "https://example.com/c", excludeFromSync: true, lastSyncedAt: new Date(NOW - HOUR - 1 * DAY).toISOString(), }); assert.ok(parsed); assert.equal("excludeFromSync" in parsed, false); const r = due([{ slug: "stale", config: parsed }], model({ channels: {} })); assert.deepEqual(r.due, ["stale"]); assert.deepEqual(r.skipped, []); }); test("the schedule projection agrees with the scheduler about who is skipped", () => { const channels = [ channel("normal", 1 * HOUR), channel("sync-paused", 1 * DAY), channel("base-paused", 1 * DAY), channel("sync-only", 1 * HOUR), channel("no-url", 1 * DAY, { url: undefined }), channel("interval-off", 1 * DAY, { syncIntervalMinutes: 0 }), ]; const priority = model({ channels: { "sync-paused": { tier: "normal", overrides: { sync: "paused" } }, "base-paused": { tier: "paused" }, "sync-only": { tier: "paused", overrides: { sync: "normal" } }, }, }); const eligible = buildScheduleView({ channels, scheduler: scheduler(), state: emptySchedulerState(), now: NOW, priority, }) .filter((v) => v.autoSyncEligible) .map((v) => v.slug); assert.deepEqual(eligible, ["normal", "sync-only"]); // Every channel here is overdue if it is eligible at all, so the two answers // are the same set — which is the invariant the console depends on. assert.deepEqual([...due(channels, priority).due].sort(), [...eligible].sort()); }); test("the projection is unchanged by an empty priority document", () => { const view = buildScheduleView({ channels: [ channel("a", 1 * HOUR), channel("b", 1 * DAY, { syncIntervalMinutes: 0 }), ], scheduler: scheduler(), state: emptySchedulerState(), now: NOW, priority: NO_MODEL, }); assert.deepEqual( view.map((v) => [v.slug, v.autoSyncEligible]), [ ["a", true], ["b", false], ], ); });