const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); const mockCacheStore = new Map(); const mockCacheTtls = new Map(); jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), cacheSet: async (k, v, ttl) => { mockCacheStore.set(k, v); mockCacheTtls.set(k, ttl); return true; }, cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, isDegraded: () => false, })); const adapter = require('../../src/services/adapters/tank01MlbAdapter'); beforeEach(() => { mockAxiosGet.mockReset(); mockCacheStore.clear(); mockCacheTtls.clear(); }); describe('tank01MlbAdapter', () => { describe('graceful degradation (no RAPID_API_KEY)', () => { const original = process.env.RAPID_API_KEY; beforeAll(() => { delete process.env.RAPID_API_KEY; }); afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; }); test('hasApiKey false', () => { expect(adapter.hasApiKey()).toBe(false); }); test('all endpoints return null without touching axios', async () => { expect(await adapter.getMLBBoxScore('20260611_ATL_NYM')).toBeNull(); expect(await adapter.getMLBBatterVsPitcher('B1', 'P1')).toBeNull(); expect(await adapter.getMLBDailyScoreboard('20260611')).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); describe('with key configured', () => { beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; }); test('RapidAPI host header is the MLB-specific Tank01 host', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); await adapter.getMLBDailyScoreboard('20260611'); const [url, opts] = mockAxiosGet.mock.calls[0]; expect(url).toMatch(/^https:\/\/tank01-mlb-live-in-game-real-time-statistics\.p\.rapidapi\.com/); expect(opts.headers['x-rapidapi-host']).toBe('tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com'); }); test('TANK01_MLB_HOST override is honored', async () => { const original = process.env.TANK01_MLB_HOST; process.env.TANK01_MLB_HOST = 'alt-mlb.rapidapi.com'; mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); await adapter.getMLBDailyScoreboard('20260611'); const [url] = mockAxiosGet.mock.calls[0]; expect(url).toMatch(/^https:\/\/alt-mlb\.rapidapi\.com/); if (original !== undefined) process.env.TANK01_MLB_HOST = original; else delete process.env.TANK01_MLB_HOST; }); test('getMLBBoxScore tags batters and pitchers with role', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: { gameStatus: 'Final', playerStats: { batting: { 'B1': { longName: 'Ronald Acuña', teamAbv: 'ATL' } }, pitching: { 'P1': { longName: 'Spencer Strider', teamAbv: 'ATL' } }, }, }, }, }); const stats = await adapter.getMLBBoxScore('GAME-1'); expect(stats.find((s) => s.role === 'batter').name).toBe('Ronald Acuña'); expect(stats.find((s) => s.role === 'pitcher').name).toBe('Spencer Strider'); // Final → 24h TTL. expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-1')).toBe(adapter.__internals.TTL.boxScoreFinal); }); test('In-progress game keeps 5-min TTL on the box score', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: { gameStatus: 'InProgress', playerStats: { batting: {}, pitching: {} } } }, }); await adapter.getMLBBoxScore('GAME-2'); expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-2')).toBe(adapter.__internals.TTL.boxScoreLive); }); test('getMLBBatterVsPitcher projects single-object payload', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: { batterID: 'B1', pitcherID: 'P1', PA: 18, AB: 16, H: 5, HR: 1, RBI: 3, SO: 4, AVG: '.313', OPS: '.857' }, }, }); const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1'); expect(bvp).toMatchObject({ batterId: 'B1', pitcherId: 'P1', plateAppearances: 18, atBats: 16, hits: 5, homeRuns: 1, rbi: 3, strikeouts: 4, avg: '.313', ops: '.857', }); }); test('getMLBBatterVsPitcher handles array of matchups', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: [ { batterID: 'B1', pitcherID: 'P1', PA: 12, H: 3, SO: 5 }, { batterID: 'B1', pitcherID: 'P1', PA: 6, H: 2, SO: 1 }, ], }, }); const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1'); expect(Array.isArray(bvp)).toBe(true); expect(bvp).toHaveLength(2); expect(bvp[0].plateAppearances).toBe(12); }); test('null IDs return null without touching axios', async () => { expect(await adapter.getMLBBoxScore(null)).toBeNull(); expect(await adapter.getMLBBatterVsPitcher(null, 'P1')).toBeNull(); expect(await adapter.getMLBBatterVsPitcher('B1', null)).toBeNull(); expect(await adapter.getMLBDailyScoreboard(null)).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('getMLBDailyScoreboard projects both array + map shapes', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: { 'GAME-X': { gameID: 'GAME-X', home: 'NYY', away: 'BOS', homePts: 5, awayPts: 4, gameStatus: 'Final' } } }, }); const games = await adapter.getMLBDailyScoreboard('20260611'); expect(games).toHaveLength(1); expect(games[0]).toMatchObject({ gameId: 'GAME-X', homeTeam: 'NYY', awayTeam: 'BOS', homeScore: 5, awayScore: 4 }); }); test('cache hit on repeat call', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: { batterID: 'B', PA: 1 } } }); await adapter.getMLBBatterVsPitcher('B', 'P'); await adapter.getMLBBatterVsPitcher('B', 'P'); expect(mockAxiosGet).toHaveBeenCalledTimes(1); }); test('axios throw → stale fallback', async () => { mockCacheStore.set('tank01:mlb:scoreboard:20260611:stale', { body: [{ gameID: 'STALE' }] }); mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500')); const games = await adapter.getMLBDailyScoreboard('20260611'); expect(games[0].gameId).toBe('STALE'); }); // Session 23 — game-level book-by-book betting odds. test('getMLBBettingOdds returns the raw body and caches at the odds TTL', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: { '20260612_ARI@CIN': { sportsBooks: [{ sportsBook: 'bet365', odds: {} }] } } }, }); const body = await adapter.getMLBBettingOdds('2026-06-12'); expect(body['20260612_ARI@CIN']).toBeDefined(); const [url] = mockAxiosGet.mock.calls[0]; expect(url).toMatch(/getMLBBettingOdds\?gameDate=20260612/); expect(mockCacheTtls.get('tank01:mlb:odds:20260612')).toBe(adapter.__internals.TTL.odds); }); test('getMLBBettingOdds second call within TTL does not hit Tank01', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { body: { g: {} } } }); await adapter.getMLBBettingOdds('2026-06-12'); await adapter.getMLBBettingOdds('2026-06-12'); expect(mockAxiosGet).toHaveBeenCalledTimes(1); }); test('getMLBBettingOdds null date returns null without axios', async () => { expect(await adapter.getMLBBettingOdds(null)).toBeNull(); }); }); });