// Tank01 daily prefetch (Session 15) — orchestrator that pulls the // Redis cache keys session 14's augmentor reads. Tests cover: // - graceful skip when adapter has no API key // - budget cap respected // - dry-run suppresses adapter calls // - final-status box scores get pulled, non-final skipped // - empty slate doesn't crash const mockNbaGames = jest.fn(); const mockNbaBox = jest.fn(); const mockNbaOdds = jest.fn(); const mockNbaHasKey = jest.fn(() => true); jest.mock('../../src/services/adapters/tank01NbaAdapter', () => ({ getNBAGamesForDate: (...a) => mockNbaGames(...a), getNBABoxScore: (...a) => mockNbaBox(...a), getNBABettingOdds: (...a) => mockNbaOdds(...a), hasApiKey: (...a) => mockNbaHasKey(...a), })); const mockMlbSlate = jest.fn(); const mockMlbBox = jest.fn(); const mockMlbBvp = jest.fn(); const mockMlbHasKey = jest.fn(() => true); jest.mock('../../src/services/adapters/tank01MlbAdapter', () => ({ getMLBDailyScoreboard: (...a) => mockMlbSlate(...a), getMLBBoxScore: (...a) => mockMlbBox(...a), getMLBBatterVsPitcher: (...a) => mockMlbBvp(...a), hasApiKey: (...a) => mockMlbHasKey(...a), })); const prefetch = require('../../scripts/tank01-prefetch'); beforeEach(() => { mockNbaGames.mockReset(); mockNbaBox.mockReset(); mockNbaOdds.mockReset(); mockNbaHasKey.mockReset().mockReturnValue(true); mockMlbSlate.mockReset(); mockMlbBox.mockReset(); mockMlbBvp.mockReset(); mockMlbHasKey.mockReset().mockReturnValue(true); }); describe('parseArgs', () => { test('defaults', () => { const a = prefetch.__internals.parseArgs(['node', 'script']); expect(a.maxRequests).toBe(prefetch.__internals.DEFAULT_BUDGET); expect(a.dryRun).toBe(false); expect(a.sports).toEqual(['nba', 'mlb']); }); test('--max=N', () => { expect(prefetch.__internals.parseArgs(['node', 's', '--max=25']).maxRequests).toBe(25); }); test('--max ignores non-numeric / non-positive', () => { expect(prefetch.__internals.parseArgs(['node', 's', '--max=0']).maxRequests).toBe(80); expect(prefetch.__internals.parseArgs(['node', 's', '--max=foo']).maxRequests).toBe(80); }); test('--dry-run', () => { expect(prefetch.__internals.parseArgs(['node', 's', '--dry-run']).dryRun).toBe(true); }); test('--sports filter', () => { expect(prefetch.__internals.parseArgs(['node', 's', '--sports=mlb']).sports).toEqual(['mlb']); }); }); describe('budget tracker', () => { test('counts spend, refuses past cap', () => { const b = prefetch.__internals.makeBudget(3); expect(b.canSpend()).toBe(true); b.spend(); b.spend(); b.spend(); expect(b.canSpend()).toBe(false); expect(b.spent()).toBe(3); }); }); describe('main — NBA path', () => { test('skips entirely when adapter has no API key', async () => { mockNbaHasKey.mockReturnValueOnce(false); mockMlbHasKey.mockReturnValueOnce(false); const r = await prefetch.main(['node', 'script', '--sports=nba']); expect(r.nba.skipped).toBe('no_key'); expect(mockNbaGames).not.toHaveBeenCalled(); }); test('pulls slate, then box score per FINAL game, plus odds once', async () => { mockNbaGames.mockResolvedValueOnce([ { gameId: 'G1', gameStatus: 'Final' }, { gameId: 'G2', gameStatus: 'InProgress' }, // skip { gameId: 'G3', gameStatus: 'Final' }, ]); mockNbaBox.mockResolvedValue({}); mockNbaOdds.mockResolvedValueOnce({}); const r = await prefetch.main(['node', 'script', '--sports=nba']); expect(r.nba.games).toBe(3); expect(r.nba.boxscores).toBe(2); // only the Finals expect(r.nba.odds).toBe(true); expect(mockNbaBox).toHaveBeenCalledTimes(2); }); test('budget cap stops fetches mid-loop', async () => { mockNbaGames.mockResolvedValueOnce( Array.from({ length: 10 }, (_, i) => ({ gameId: `G${i}`, gameStatus: 'Final' })), ); mockNbaBox.mockResolvedValue({}); mockNbaOdds.mockResolvedValueOnce({}); // Budget = 4: one for getNBAGamesForDate, leaves 3 box-score // slots (no odds — budget exhausted first). const r = await prefetch.main(['node', 'script', '--sports=nba', '--max=4']); expect(mockNbaGames).toHaveBeenCalledTimes(1); expect(mockNbaBox).toHaveBeenCalledTimes(3); expect(mockNbaOdds).not.toHaveBeenCalled(); expect(r.requestsSpent).toBe(4); }); test('dry-run skips ALL adapter calls', async () => { const r = await prefetch.main(['node', 'script', '--dry-run']); expect(mockNbaGames).not.toHaveBeenCalled(); expect(mockNbaBox).not.toHaveBeenCalled(); expect(mockNbaOdds).not.toHaveBeenCalled(); expect(r.nba.skipped).toBe('dry_run'); expect(r.mlb.skipped).toBe('dry_run'); }); test('empty slate is not an error', async () => { mockNbaGames.mockResolvedValueOnce([]); mockNbaOdds.mockResolvedValueOnce({}); const r = await prefetch.main(['node', 'script', '--sports=nba']); expect(r.nba.games).toBe(0); expect(r.nba.boxscores).toBe(0); // Odds still pulled — it's a single daily call, not per-game. expect(r.nba.odds).toBe(true); }); test('null slate (adapter returned null) is not an error', async () => { mockNbaGames.mockResolvedValueOnce(null); const r = await prefetch.main(['node', 'script', '--sports=nba']); expect(r.nba.games).toBe(0); expect(r.nba.boxscores).toBe(0); }); }); describe('main — MLB path', () => { test('skips when adapter has no API key', async () => { mockMlbHasKey.mockReturnValueOnce(false); const r = await prefetch.main(['node', 'script', '--sports=mlb']); expect(r.mlb.skipped).toBe('no_key'); }); test('pulls scoreboard + box scores for Finals/Completed', async () => { mockMlbSlate.mockResolvedValueOnce([ { gameId: 'M1', gameStatus: 'Final' }, { gameId: 'M2', gameStatus: 'In Progress' }, // skip { gameId: 'M3', gameStatus: 'Completed' }, ]); mockMlbBox.mockResolvedValue({}); const r = await prefetch.main(['node', 'script', '--sports=mlb']); expect(r.mlb.games).toBe(3); expect(r.mlb.boxscores).toBe(2); expect(r.mlb.bvp_skipped_reason).toMatch(/scoreboard/i); }); });