const { getOdds, getCacheKey, getQuotaKey, updateQuota, getQuotaRemaining, CACHE_TTL } = require('../../src/services/oddsService'); // Mock Redis const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn(), }; jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis, })); // Mock axios jest.mock('axios'); const axios = require('axios'); // Set API key for tests process.env.ODDS_API_KEY = 'test-api-key'; const MOCK_EVENT = { id: 'event-1', sport_key: 'basketball_nba', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z', }; const MOCK_EVENT_WITH_ODDS = { ...MOCK_EVENT, bookmakers: [ { key: 'draftkings', title: 'DraftKings', markets: [ { key: 'player_points', last_update: '2026-03-21T14:28:00Z', outcomes: [ { name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 }, { name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 }, ], }, ], }, ], }; function mockAxiosSuccess() { axios.get .mockResolvedValueOnce({ data: [MOCK_EVENT], headers: { 'x-requests-remaining': '490', 'x-requests-used': '10' }, }) .mockResolvedValueOnce({ data: MOCK_EVENT_WITH_ODDS, headers: { 'x-requests-remaining': '489', 'x-requests-used': '11' }, }); } beforeEach(() => { jest.clearAllMocks(); mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockRedis.hset.mockResolvedValue(1); mockRedis.hgetall.mockResolvedValue({}); mockRedis.expire.mockResolvedValue(1); }); describe('oddsService', () => { describe('getOdds - cache hit', () => { it('returns cached data when cache is fresh (no API call made)', async () => { const cachedData = { updated_at: '2026-03-21T14:00:00Z', props: [{ player: 'Jokic', stat_type: 'points' }], }; mockRedis.get.mockResolvedValue(JSON.stringify(cachedData)); mockRedis.hgetall.mockResolvedValue({ remaining: '450' }); const result = await getOdds('nba'); expect(result.source).toBe('cache'); expect(result.props).toEqual(cachedData.props); expect(result.quota_remaining).toBe(450); expect(axios.get).not.toHaveBeenCalled(); }); }); describe('getOdds - cache miss', () => { it('calls Odds API when cache is empty', async () => { mockAxiosSuccess(); const result = await getOdds('nba'); expect(result.source).toBe('live'); expect(result.props.length).toBeGreaterThan(0); expect(result.props[0].player).toBe('Nikola Jokic'); expect(axios.get).toHaveBeenCalledTimes(2); // events + 1 event odds }); it('stores normalized data in Redis after successful fetch', async () => { mockAxiosSuccess(); await getOdds('nba'); expect(mockRedis.set).toHaveBeenCalledWith( expect.stringMatching(/^odds:nba:/), expect.any(String), 'EX', CACHE_TTL ); }); }); describe('getOdds - API failure', () => { it('returns stale cache on API failure', async () => { // First call: no cache -> will try API mockRedis.get .mockResolvedValueOnce(null) // initial cache check .mockResolvedValueOnce(JSON.stringify({ // stale cache fallback updated_at: '2026-03-21T12:00:00Z', props: [{ player: 'Stale Jokic' }], })); axios.get.mockRejectedValue(new Error('API timeout')); const result = await getOdds('nba'); expect(result.source).toBe('cache'); expect(result.stale).toBe(true); expect(result.props[0].player).toBe('Stale Jokic'); }); it('throws 503 when API fails and no cache exists', async () => { mockRedis.get.mockResolvedValue(null); axios.get.mockRejectedValue(new Error('API down')); await expect(getOdds('nba')).rejects.toMatchObject({ message: 'Odds service unavailable.', statusCode: 503, }); }); }); describe('getOdds - quota management', () => { it('tracks quota from response headers', async () => { mockAxiosSuccess(); await getOdds('nba'); expect(mockRedis.hset).toHaveBeenCalledWith( expect.stringMatching(/^odds:quota:/), 'remaining', '489', 'used', '11', 'last_checked', expect.any(String) ); }); it('blocks fetches when quota is 0', async () => { mockRedis.hgetall.mockResolvedValue({ remaining: '0' }); await expect(getOdds('nba')).rejects.toMatchObject({ message: 'Odds data temporarily unavailable. Try again later.', statusCode: 429, }); expect(axios.get).not.toHaveBeenCalled(); }); }); describe('utility functions', () => { it('getCacheKey uses UTC date', () => { const key = getCacheKey('nba'); expect(key).toMatch(/^odds:nba:\d{4}-\d{2}-\d{2}$/); }); it('getQuotaKey uses UTC year-month', () => { const key = getQuotaKey(); expect(key).toMatch(/^odds:quota:\d{4}-\d{2}$/); }); }); });