process.env.SHARPAPI_KEY = 'test-key'; process.env.SHARPAPI_BASE_URL = 'https://api.sharpapi.test/v1'; const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args), })); const mockCache = { current: new Map() }; jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => mockCache.current.get(k) ?? null, cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; }, cacheDel: async (k) => { mockCache.current.delete(k); return true; }, })); const adapter = require('../../src/services/adapters/sharpApiAdapter'); beforeEach(() => { mockAxiosGet.mockReset(); mockCache.current.clear(); }); describe('sharpApiAdapter.configured', () => { test('reflects SHARPAPI_KEY env presence', () => { expect(adapter.configured()).toBe(true); delete process.env.SHARPAPI_KEY; expect(adapter.configured()).toBe(false); process.env.SHARPAPI_KEY = 'test-key'; }); }); describe('getPlayerProps', () => { test('throws on unsupported sport', async () => { await expect(adapter.getPlayerProps('curling', 'g1')).rejects.toThrow(/Unsupported sport/); }); test('normalizes the response and computes fair probabilities', async () => { mockAxiosGet.mockResolvedValue({ status: 200, data: { props: [ { book: 'dk', player: 'LeBron James', stat_type: 'points', line: 25.5, over_price: -110, under_price: -110 }, ], }, }); const result = await adapter.getPlayerProps('nba', 'game-1'); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ book: 'dk', player: 'LeBron James', statType: 'points', line: 25.5 }); expect(result[0].fairOver).toBeCloseTo(0.5, 5); expect(result[0].fairUnder).toBeCloseTo(0.5, 5); }); test('cache hit on second call avoids a second request', async () => { mockAxiosGet.mockResolvedValue({ status: 200, data: { props: [] } }); await adapter.getPlayerProps('nba', 'game-cache'); await adapter.getPlayerProps('nba', 'game-cache'); expect(mockAxiosGet).toHaveBeenCalledTimes(1); }); test('429 response serves prior stale cache marked stale:true', async () => { // Simulate "cache exists but is already stale" — in production this is // what an expired-EX Redis entry plus a previous 429 retention path // would look like. The mock cache doesn't expire so we mark it stale // directly to force the refresh branch. mockCache.current.set('odds:nba:game-stale:player_props', { props: [{ book: 'dk', player: 'Old', stat_type: 'points', line: 20, over_price: -110, under_price: -110 }], stale: true, }); mockAxiosGet.mockResolvedValue({ status: 429, data: {} }); const result = await adapter.getPlayerProps('nba', 'game-stale'); expect(result.stale).toBe(true); expect(result).toHaveLength(1); }); }); describe('getGameOdds', () => { test('returns spread/total/moneyline shape', async () => { mockAxiosGet.mockResolvedValue({ status: 200, data: { spread: { home: -3.5 }, total: { line: 220.5 }, h2h: { home: -150 } }, }); const result = await adapter.getGameOdds('nba', 'g99'); expect(result).toMatchObject({ spread: { home: -3.5 }, total: { line: 220.5 }, moneyline: { home: -150 }, }); }); test('returns null when adapter is unconfigured', async () => { delete process.env.SHARPAPI_KEY; const result = await adapter.getGameOdds('nba', 'g99'); expect(result).toBeNull(); process.env.SHARPAPI_KEY = 'test-key'; }); }); describe('getConsensusLine', () => { test('returns median/min/max across books, ignoring unrelated props', async () => { mockAxiosGet.mockResolvedValue({ status: 200, data: { props: [ { book: 'dk', player: 'Anthony Edwards', stat_type: 'points', line: 27.5, over_price: -115, under_price: -105 }, { book: 'fd', player: 'Anthony Edwards', stat_type: 'points', line: 28.5, over_price: -110, under_price: -110 }, { book: 'mgm', player: 'Anthony Edwards', stat_type: 'points', line: 27.0, over_price: -120, under_price: +100 }, { book: 'dk', player: 'Different Player', stat_type: 'points', line: 99.0 }, // ignored ], }, }); const consensus = await adapter.getConsensusLine('nba', 'g-c', 'Anthony Edwards', 'points'); expect(consensus.bookCount).toBe(3); expect(consensus.median).toBe(27.5); expect(consensus.min).toBe(27.0); expect(consensus.max).toBe(28.5); }); test('returns null when no matching prop', async () => { mockAxiosGet.mockResolvedValue({ status: 200, data: { props: [] } }); const result = await adapter.getConsensusLine('nba', 'g-x', 'Ghost', 'points'); expect(result).toBeNull(); }); });