mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
* feat(radar): sync referral links from standalone /v1/referrals/latest feed Referral links previously came from the catalog feed cache, which on the community tier can be up to 30 days stale -- a newly-added referral would not reach a free/community user for up to a month. Adds a new sync module (syncRadarReferrals), Ed25519-verified feed schema, and a dedicated radar_referrals_cache table (migration 142) so referrals sync on their own, much shorter cadence instead of inheriting the catalog's delay. getRadarReferrals()/getDefaultReferralFor() now read the new cache instead of the catalog feed's embedded referrals field (kept on RadarFeedSchema for backward-compat with already-cached catalog feeds, but no longer read). * feat(radar): wire sync-on-read + scheduler side-sync for referrals GET /api/radar/referrals now triggers syncRadarReferrals() inline whenever the cache is missing or older than 1h (shouldSyncReferralsOnRead), so fixed links show up promptly on the next dashboard load instead of waiting on a background timer. The route itself still never talks to the upstream feed server directly -- syncRadarReferrals() remains the only network touchpoint. radarSchedulerTick() also evaluates referrals staleness on the same hourly tick used for the catalog, independent of the catalog's own due-ness, as a best-effort side effect that never changes RadarTickResult's shape and is swallowed on error. * docs(radar): document the standalone referrals feed sync Explains the /v1/referrals/latest feed, its no-tier-field-in-body design (x-omniroute-feed-tier header is the only tier source), the sync-on-read + scheduler side-sync triggers, and the self-hosting note for forks that only serve the catalog feed. --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
235 lines
9.6 KiB
TypeScript
235 lines
9.6 KiB
TypeScript
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<typeof setInterval>;
|
|
}) as typeof setInterval,
|
|
clearIntervalFn: (() => {
|
|
cleared += 1;
|
|
}) as typeof clearInterval,
|
|
};
|
|
}
|
|
|
|
function deps(overrides: Record<string, unknown> = {}) {
|
|
const syncCalls: number[] = [];
|
|
const referralsSyncCalls: number[] = [];
|
|
const timers = fakeTimers();
|
|
return {
|
|
syncCalls,
|
|
referralsSyncCalls,
|
|
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;
|
|
},
|
|
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);
|
|
});
|
|
});
|