import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; // Isolate DATA_DIR before any src import — the scheduler module's default deps // reference the DB layer (never invoked here: every test injects its deps). const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-radar-scheduler-")); process.env.DATA_DIR = tmpDir; const { radarSchedulerTick, ensureRadarSyncScheduler, stopRadarSyncScheduler, initRadarSyncScheduler, RADAR_SCHEDULER_TICK_MS, } = await import("../../src/lib/radar/scheduler.ts"); const NOW = Date.parse("2026-08-06T12:00:00.000Z"); const FRESH = new Date(NOW - 60 * 60 * 1000).toISOString(); // 1h ago — inside the daily window const STALE = new Date(NOW - 25 * 60 * 60 * 1000).toISOString(); // 25h ago — due // Referrals staleness window is much shorter (1h, see REFERRALS_STALE_MS) — // this default must sit well inside it so existing catalog-only subtests // never trigger a referrals sync as an unasserted side effect. const REFERRALS_FRESH = new Date(NOW - 5 * 60 * 1000).toISOString(); // 5m ago const REFERRALS_STALE = new Date(NOW - 2 * 60 * 60 * 1000).toISOString(); // 2h ago — due /** Fake interval registry so no real timer ever exists in these tests. */ function fakeTimers() { const registered: Array<{ fn: () => void; ms: number }> = []; let cleared = 0; return { registered, clearedCount: () => cleared, setIntervalFn: ((fn: () => void, ms: number) => { registered.push({ fn, ms }); return registered.length as unknown as ReturnType; }) as typeof setInterval, clearIntervalFn: (() => { cleared += 1; }) as typeof clearInterval, }; } function deps(overrides: Record = {}) { const syncCalls: number[] = []; const referralsSyncCalls: number[] = []; const offersSyncCalls: number[] = []; const intelSyncCalls: number[] = []; const timers = fakeTimers(); return { syncCalls, referralsSyncCalls, offersSyncCalls, intelSyncCalls, timers, d: { getFlag: () => true, getSettings: () => ({ optIn: true }), getCache: () => ({ fetchedAt: STALE }), sync: async () => { syncCalls.push(1); return { status: "updated", version: "2026.08.06.1", tier: "live" } as const; }, // Referrals side-sync — separate cache/sync from the catalog above. // Defaults to a FRESH referrals cache so existing subtests (which // don't care about referrals at all) never trigger a referrals sync // as an unasserted side effect. getReferralsCache: () => ({ fetchedAt: REFERRALS_FRESH }), syncReferrals: async () => { referralsSyncCalls.push(1); return { status: "updated", generatedAt: "2026-08-06T12:00:00.000Z", tier: "live", } as const; }, getOffersCache: () => ({ fetchedAt: FRESH }), syncOffers: async () => { offersSyncCalls.push(1); return { status: "updated", version: "2026.08.06.1" } as const; }, getIntelCache: () => ({ fetchedAt: FRESH }), syncIntel: async () => { intelSyncCalls.push(1); return { status: "updated", version: "2026.08.06.1" } as const; }, now: () => NOW, setIntervalFn: timers.setIntervalFn, clearIntervalFn: timers.clearIntervalFn, ...overrides, }, }; } test("radar sync scheduler", async (t) => { t.afterEach(() => { // Module-level timer state must not leak between subtests. stopRadarSyncScheduler({ clearIntervalFn: (() => {}) as typeof clearInterval }); }); await t.test("tick: flag off => stopped, sync never called", async () => { const { d, syncCalls } = deps({ getFlag: () => false }); const result = await radarSchedulerTick(d); assert.deepEqual(result, { action: "stopped", reason: "flag_off" }); assert.equal(syncCalls.length, 0); }); await t.test("tick: flag off stops a running timer (self-heal to zero-timer state)", async () => { const { d, timers } = deps(); assert.equal(ensureRadarSyncScheduler(d), true); assert.equal(timers.registered.length, 1); const offDeps = { ...d, getFlag: () => false }; await radarSchedulerTick(offDeps); assert.equal(timers.clearedCount(), 1); }); await t.test("tick: opt-in off => skipped, no sync", async () => { const { d, syncCalls } = deps({ getSettings: () => ({ optIn: false }) }); const result = await radarSchedulerTick(d); assert.deepEqual(result, { action: "skipped", reason: "opt_out" }); assert.equal(syncCalls.length, 0); }); await t.test("tick: fresh cache => not due, no sync", async () => { const { d, syncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }) }); const result = await radarSchedulerTick(d); assert.deepEqual(result, { action: "skipped", reason: "not_due" }); assert.equal(syncCalls.length, 0); }); await t.test("tick: no cache at all => syncs immediately", async () => { const { d, syncCalls } = deps({ getCache: () => null }); const result = await radarSchedulerTick(d); assert.equal(result.action, "synced"); assert.equal(syncCalls.length, 1); }); await t.test("tick: stale cache (>24h) => syncs", async () => { const { d, syncCalls } = deps(); const result = await radarSchedulerTick(d); assert.equal(result.action, "synced"); assert.equal(syncCalls.length, 1); }); await t.test( "ensure: registers one hourly timer, fires an immediate tick, idempotent", async () => { const { d, timers, syncCalls } = deps(); assert.equal(ensureRadarSyncScheduler(d), true); assert.equal(timers.registered.length, 1); assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS); // The immediate tick is fire-and-forget; give the microtask queue a turn. await new Promise((resolve) => setImmediate(resolve)); assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache"); // Second ensure is a no-op — no second timer. assert.equal(ensureRadarSyncScheduler(d), false); assert.equal(timers.registered.length, 1); } ); await t.test("init: flag off => never arms (flag-off boot stays timer-free)", () => { const { d, timers } = deps({ getFlag: () => false }); assert.equal(initRadarSyncScheduler(d), false); assert.equal(timers.registered.length, 0); }); await t.test("init: opt-in off => never arms", () => { const { d, timers } = deps({ getSettings: () => ({ optIn: false }) }); assert.equal(initRadarSyncScheduler(d), false); assert.equal(timers.registered.length, 0); }); await t.test("init: flag + opt-in on => arms the timer", () => { const { d, timers } = deps(); assert.equal(initRadarSyncScheduler(d), true); assert.equal(timers.registered.length, 1); }); await t.test("init: settings reader throwing => false, never throws out", () => { const { d, timers } = deps({ getSettings: () => { throw new Error("db unavailable"); }, }); assert.equal(initRadarSyncScheduler(d), false); assert.equal(timers.registered.length, 0); }); // ------------------------------------------------------------------------- // Referrals side-sync — piggybacks on the same hourly tick but its own // (much shorter, 1h) staleness window, independent of the catalog's // due-ness. Never surfaces in RadarTickResult (fire-and-await side effect // only) so the catalog-sync result shape/assertions above stay unchanged. // ------------------------------------------------------------------------- await t.test( "tick: referrals cache fresh => referrals sync NOT called (catalog path unaffected)", async () => { const { d, syncCalls, referralsSyncCalls } = deps(); const result = await radarSchedulerTick(d); assert.equal(result.action, "synced", "catalog was due and must still sync as before"); assert.equal(syncCalls.length, 1); assert.equal(referralsSyncCalls.length, 0, "referrals cache was fresh — must not sync"); } ); await t.test( "tick: referrals cache stale => referrals sync called, independent of catalog due-ness", async () => { const { d, syncCalls, referralsSyncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }), // catalog NOT due getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), // referrals due }); const result = await radarSchedulerTick(d); assert.deepEqual( result, { action: "skipped", reason: "not_due" }, "catalog result shape must stay unchanged" ); assert.equal(syncCalls.length, 0, "catalog must not sync — it was not due"); assert.equal(referralsSyncCalls.length, 1, "referrals were due and must sync independently"); } ); await t.test( "tick: referrals cache missing => referrals sync called (missing counts as stale)", async () => { const { d, referralsSyncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }), getReferralsCache: () => null, }); await radarSchedulerTick(d); assert.equal(referralsSyncCalls.length, 1); } ); await t.test( "tick: flag off => referrals sync NOT called (stopped before any sync check)", async () => { const { d, referralsSyncCalls } = deps({ getFlag: () => false, getReferralsCache: () => null, // would be due if ever reached }); await radarSchedulerTick(d); assert.equal(referralsSyncCalls.length, 0); } ); await t.test( "tick: opt-in off => referrals sync NOT called (skipped before any sync check)", async () => { const { d, referralsSyncCalls } = deps({ getSettings: () => ({ optIn: false }), getReferralsCache: () => null, // would be due if ever reached }); await radarSchedulerTick(d); assert.equal(referralsSyncCalls.length, 0); } ); await t.test( "tick: referrals sync throwing => swallowed, catalog tick still completes normally", async () => { const { d, syncCalls } = deps({ getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), syncReferrals: async () => { throw new Error("referrals upstream exploded"); }, }); const result = await radarSchedulerTick(d); assert.equal( result.action, "synced", "a throwing referrals sync must never break the catalog tick" ); assert.equal(syncCalls.length, 1); } ); await t.test("tick: offers and Intel use independent staleness gates", async () => { const { d, syncCalls, offersSyncCalls, intelSyncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }), getOffersCache: () => ({ fetchedAt: STALE }), getIntelCache: () => null, }); const result = await radarSchedulerTick(d); assert.deepEqual(result, { action: "skipped", reason: "not_due" }); assert.equal(syncCalls.length, 0); assert.equal(offersSyncCalls.length, 1); assert.equal(intelSyncCalls.length, 1); }); });