// Odds-cache prewarmer (Session 22). // // Pins the contract that matters: // 1. ODDS_PREWARM unset → script no-ops (free tier safety) // 2. --dry-run → no provider calls, summary records the sports // 3. Happy path → getOdds called per sport, summary captures source/props/quota // 4. Quota-block mid-run → remaining sports skip with the right reason // 5. parseArgs handles --sports + --dry-run + defaults const path = require('path'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'odds-prefetch.js'); // Mock the two modules the script lazy-requires. We don't preload // the script — jest.isolateModules around each test gives us a // fresh module cache so the env flag is re-read. jest.mock('../../src/services/oddsService', () => ({ getOdds: jest.fn(), })); jest.mock('../../src/services/quotaTracker', () => ({ getQuotaStatus: jest.fn(), })); const oddsService = require('../../src/services/oddsService'); const quotaTracker = require('../../src/services/quotaTracker'); const { main, __internals } = require(SCRIPT); beforeEach(() => { oddsService.getOdds.mockReset(); quotaTracker.getQuotaStatus.mockReset(); // Default healthy quota — tests override per-case. quotaTracker.getQuotaStatus.mockResolvedValue({ allowed: true, used: 50, limit: 500, pct: 0.1, }); delete process.env.ODDS_PREWARM; }); describe('parseArgs', () => { test('defaults to nba,wnba,mlb non-dry-run', () => { const a = __internals.parseArgs(['node', 'odds-prefetch.js']); expect(a.sports).toEqual(['nba', 'wnba', 'mlb']); expect(a.dryRun).toBe(false); }); test('honors --sports=', () => { const a = __internals.parseArgs(['node', 's', '--sports=nba']); expect(a.sports).toEqual(['nba']); }); test('honors --dry-run', () => { const a = __internals.parseArgs(['node', 's', '--dry-run']); expect(a.dryRun).toBe(true); }); test('handles --sports=nba,mlb,wnba in any order with --dry-run', () => { const a = __internals.parseArgs(['node', 's', '--dry-run', '--sports=nba,mlb,wnba']); expect(a.sports).toEqual(['nba', 'mlb', 'wnba']); expect(a.dryRun).toBe(true); }); }); describe('main — ODDS_PREWARM gating', () => { test('refuses to run when ODDS_PREWARM is unset', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const result = await main(['node', 'odds-prefetch.js']); expect(result.enabled).toBe(false); expect(result.skipped).toBe('not_enabled'); expect(oddsService.getOdds).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); test('refuses to run when ODDS_PREWARM is "0" or "false"', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); for (const v of ['0', 'false', 'no', '']) { process.env.ODDS_PREWARM = v; const r = await main(['node', 'odds-prefetch.js']); expect(r.skipped).toBe('not_enabled'); } expect(oddsService.getOdds).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); }); describe('main — dry-run', () => { test('does not call getOdds, records skipped:dry_run per sport', async () => { process.env.ODDS_PREWARM = '1'; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb', '--dry-run']); expect(oddsService.getOdds).not.toHaveBeenCalled(); expect(result.dryRun).toBe(true); expect(result.sports.nba).toEqual({ skipped: 'dry_run' }); expect(result.sports.mlb).toEqual({ skipped: 'dry_run' }); logSpy.mockRestore(); }); }); describe('main — happy path', () => { test('calls getOdds per sport, captures source/props/quota', async () => { process.env.ODDS_PREWARM = '1'; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); oddsService.getOdds .mockResolvedValueOnce({ source: 'live', props: [{ a: 1 }, { a: 2 }, { a: 3 }], quota_remaining: 480 }) .mockResolvedValueOnce({ source: 'cache', props: [{ a: 1 }], quota_remaining: 480 }); const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb']); expect(oddsService.getOdds).toHaveBeenCalledTimes(2); expect(result.sports.nba).toMatchObject({ source: 'live', props: 3, quota_remaining: 480 }); expect(result.sports.mlb).toMatchObject({ source: 'cache', props: 1 }); logSpy.mockRestore(); }); test('captures credits spent as the delta between before/after tracker reads', async () => { process.env.ODDS_PREWARM = '1'; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); quotaTracker.getQuotaStatus .mockResolvedValueOnce({ allowed: true, used: 50, limit: 500, pct: 0.1 }) // initial .mockResolvedValueOnce({ allowed: true, used: 50, limit: 500, pct: 0.1 }) // pre-nba check .mockResolvedValueOnce({ allowed: true, used: 57, limit: 500, pct: 0.114 }); // final oddsService.getOdds.mockResolvedValueOnce({ source: 'live', props: [], quota_remaining: 443 }); const result = await main(['node', 'odds-prefetch.js', '--sports=nba']); expect(result.creditsSpent).toBe(7); // 57 - 50 expect(result.quota.after).toBe(57); logSpy.mockRestore(); }); }); describe('main — quota-blocked mid-run', () => { test('skips remaining sports when the tracker blocks', async () => { process.env.ODDS_PREWARM = '1'; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); quotaTracker.getQuotaStatus .mockResolvedValueOnce({ allowed: true, used: 470, limit: 500, pct: 0.94 }) // initial .mockResolvedValueOnce({ allowed: true, used: 470, limit: 500, pct: 0.94 }) // pre-nba .mockResolvedValueOnce({ allowed: false, used: 480, limit: 500, pct: 0.96 }) // pre-mlb .mockResolvedValueOnce({ allowed: false, used: 480, limit: 500, pct: 0.96 }) // pre-wnba .mockResolvedValueOnce({ allowed: false, used: 480, limit: 500, pct: 0.96 }); // final oddsService.getOdds.mockResolvedValueOnce({ source: 'live', props: [], quota_remaining: 20 }); const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb,wnba']); expect(oddsService.getOdds).toHaveBeenCalledTimes(1); expect(result.sports.nba.source).toBe('live'); expect(result.sports.mlb).toMatchObject({ skipped: 'quota_blocked' }); expect(result.sports.wnba).toMatchObject({ skipped: 'quota_blocked' }); logSpy.mockRestore(); warnSpy.mockRestore(); }); }); describe('main — error per sport does not break the loop', () => { test('records {error} for the failing sport, continues with the next', async () => { process.env.ODDS_PREWARM = '1'; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); oddsService.getOdds .mockRejectedValueOnce(new Error('upstream 502')) .mockResolvedValueOnce({ source: 'live', props: [{ a: 1 }], quota_remaining: 480 }); const result = await main(['node', 'odds-prefetch.js', '--sports=nba,mlb']); expect(result.sports.nba.error).toMatch(/upstream 502/); expect(result.sports.mlb.source).toBe('live'); logSpy.mockRestore(); warnSpy.mockRestore(); }); });