// Unit: provider preference + source tracking in getOdds (Session 30). const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() }; jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis, cacheGet: jest.fn(async () => null), cacheSet: jest.fn(async () => true), cacheDel: jest.fn(async () => true), isDegraded: jest.fn(() => true), // gateway/quota fail open in tests })); jest.mock('axios'); const axios = require('axios'); // PropLine adapter mock — we drive hasKeys + getProps per test. jest.mock('../../src/services/adapters/proplineAdapter', () => ({ hasKeys: jest.fn(), getProps: jest.fn(), })); const propline = require('../../src/services/adapters/proplineAdapter'); process.env.ODDS_API_KEY = 'test-api-key'; const { getOdds } = require('../../src/services/oddsService'); beforeEach(() => { jest.clearAllMocks(); mockRedis.get.mockResolvedValue(null); // cache miss mockRedis.set.mockResolvedValue('OK'); mockRedis.hgetall.mockResolvedValue({}); }); describe('getOdds — PropLine preferred', () => { test('serves from PropLine when it returns props (provider=propline, no odds-api call)', async () => { propline.hasKeys.mockReturnValue(true); propline.getProps.mockResolvedValue({ props: [{ player: 'Acuna', stat_type: 'hits', line: 0.5, over_odds: -200, under_odds: 160, book: 'betmgm' }], spreads: [], source: 'propline', }); const res = await getOdds('mlb'); expect(res.source).toBe('live'); expect(res.provider).toBe('propline'); expect(res.props).toHaveLength(1); expect(axios.get).not.toHaveBeenCalled(); // odds-api never touched // Cached payload carries the provider tag. const cachedArg = JSON.parse(mockRedis.set.mock.calls[0][1]); expect(cachedArg.provider).toBe('propline'); }); test('falls back to odds-api when PropLine returns empty (provider=odds-api)', async () => { propline.hasKeys.mockReturnValue(true); propline.getProps.mockResolvedValue({ props: [], spreads: [], source: 'propline' }); // odds-api fallback: events list, then per-event odds. axios.get .mockResolvedValueOnce({ data: [{ id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z' }], headers: {} }) .mockResolvedValueOnce({ data: { id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z', bookmakers: [{ key: 'draftkings', title: 'DK', markets: [{ key: 'player_points', last_update: 't', outcomes: [ { name: 'Over', description: 'Jokic', price: -110, point: 26.5 }, { name: 'Under', description: 'Jokic', price: -110, point: 26.5 }, ] }] }], }, headers: { 'x-requests-remaining': '400' }, }); const res = await getOdds('nba'); expect(res.provider).toBe('odds-api'); expect(axios.get).toHaveBeenCalled(); expect(res.props.length).toBeGreaterThan(0); }); test('PropLine skipped entirely when no keys (provider=odds-api)', async () => { propline.hasKeys.mockReturnValue(false); axios.get .mockResolvedValueOnce({ data: [{ id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z' }], headers: {} }) .mockResolvedValueOnce({ data: { id: 'e1', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z', bookmakers: [{ key: 'draftkings', title: 'DK', markets: [{ key: 'player_points', last_update: 't', outcomes: [ { name: 'Over', description: 'Jokic', price: -110, point: 26.5 }, { name: 'Under', description: 'Jokic', price: -110, point: 26.5 }, ] }] }] }, headers: {}, }); const res = await getOdds('nba'); expect(propline.getProps).not.toHaveBeenCalled(); expect(res.provider).toBe('odds-api'); }); test('PropLine error → graceful fallback to odds-api', async () => { propline.hasKeys.mockReturnValue(true); propline.getProps.mockRejectedValue(new Error('propline down')); axios.get .mockResolvedValueOnce({ data: [], headers: {} }); // empty events → empty props, but provider tagged const res = await getOdds('nba'); expect(res.provider).toBe('odds-api'); }); test('cached response surfaces the stored provider', async () => { mockRedis.get.mockResolvedValue(JSON.stringify({ updated_at: 't', props: [{ player: 'X' }], spreads: [], provider: 'propline' })); const res = await getOdds('mlb'); expect(res.source).toBe('cache'); expect(res.provider).toBe('propline'); }); });