const request = require('supertest'); // Mock Redis before importing app 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 env process.env.ODDS_API_KEY = 'test-key'; const app = require('../../src/app'); const MOCK_EVENTS = [ { id: 'game-1', sport_key: 'basketball_nba', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z', }, ]; const MOCK_ODDS_RESPONSE = { ...MOCK_EVENTS[0], 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 }, { name: 'Over', description: 'LeBron James', price: -115, point: 25.5 }, { name: 'Under', description: 'LeBron James', price: -105, point: 25.5 }, ], }, { key: 'player_rebounds', last_update: '2026-03-21T14:28:00Z', outcomes: [ { name: 'Over', description: 'Nikola Jokic', price: -130, point: 12.5 }, { name: 'Under', description: 'Nikola Jokic', price: 110, point: 12.5 }, ], }, ], }, { key: 'fanduel', title: 'FanDuel', markets: [ { key: 'player_points', last_update: '2026-03-21T14:30:00Z', outcomes: [ { name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 }, { name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 }, ], }, ], }, ], }; const API_HEADERS = { 'x-requests-remaining': '488', 'x-requests-used': '12', 'x-requests-last': '1', }; function setupMockApi() { axios.get .mockResolvedValueOnce({ data: MOCK_EVENTS, headers: API_HEADERS }) .mockResolvedValueOnce({ data: MOCK_ODDS_RESPONSE, headers: API_HEADERS }); } beforeEach(() => { jest.clearAllMocks(); mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockRedis.hset.mockResolvedValue(1); mockRedis.hgetall.mockResolvedValue({}); mockRedis.expire.mockResolvedValue(1); }); describe('GET /api/odds/nba', () => { it('returns full response with correct shape on live fetch', async () => { setupMockApi(); const res = await request(app).get('/api/odds/nba').expect(200); expect(res.body.sport).toBe('nba'); expect(res.body.source).toBe('live'); expect(res.body.updated_at).toBeDefined(); expect(res.body.quota_remaining).toBe(488); expect(Array.isArray(res.body.props)).toBe(true); expect(res.body.props.length).toBeGreaterThan(0); // Check grouped structure const jokicPoints = res.body.props.find( (p) => p.player === 'Nikola Jokic' && p.stat_type === 'points' ); expect(jokicPoints).toBeDefined(); expect(jokicPoints.home_team).toBe('DEN'); expect(jokicPoints.away_team).toBe('LAL'); expect(jokicPoints.lines.length).toBe(2); // draftkings + fanduel expect(jokicPoints.lines[0]).toHaveProperty('book'); expect(jokicPoints.lines[0]).toHaveProperty('line'); expect(jokicPoints.lines[0]).toHaveProperty('over_odds'); expect(jokicPoints.lines[0]).toHaveProperty('under_odds'); expect(jokicPoints.lines[0]).toHaveProperty('fetched_at'); }); it('serves cached data on second request within 15 minutes', async () => { const cachedData = { updated_at: '2026-03-21T14:00:00Z', props: [ { player: 'Nikola Jokic', home_team: 'DEN', away_team: 'LAL', game_time: '2026-03-21T19:00:00Z', stat_type: 'points', book: 'draftkings', line: 26.5, over_odds: -110, under_odds: -110, fetched_at: '2026-03-21T14:28:00Z', }, ], }; mockRedis.get.mockResolvedValue(JSON.stringify(cachedData)); const res = await request(app).get('/api/odds/nba').expect(200); expect(res.body.source).toBe('cache'); expect(axios.get).not.toHaveBeenCalled(); }); it('filters by stat_type', async () => { setupMockApi(); const res = await request(app).get('/api/odds/nba?stat_type=rebounds').expect(200); for (const prop of res.body.props) { expect(prop.stat_type).toBe('rebounds'); } }); it('filters by player (partial match, case-insensitive)', async () => { setupMockApi(); const res = await request(app).get('/api/odds/nba?player=jokic').expect(200); for (const prop of res.body.props) { expect(prop.player.toLowerCase()).toContain('jokic'); } }); it('filters by book', async () => { setupMockApi(); const res = await request(app).get('/api/odds/nba?book=fanduel').expect(200); for (const prop of res.body.props) { for (const line of prop.lines) { expect(line.book).toBe('fanduel'); } } }); it('returns 400 for invalid stat_type', async () => { const res = await request(app).get('/api/odds/nba?stat_type=invalid').expect(400); expect(res.body.error).toContain('Invalid stat_type'); }); it('returns 400 for invalid book', async () => { const res = await request(app).get('/api/odds/nba?book=bovada').expect(400); expect(res.body.error).toContain('Invalid book'); }); it('returns stale cache data when API fails', async () => { mockRedis.get .mockResolvedValueOnce(null) // cache miss .mockResolvedValueOnce(JSON.stringify({ // stale fallback updated_at: '2026-03-21T12:00:00Z', props: [{ player: 'Stale Data', home_team: 'DEN', away_team: 'LAL', game_time: '2026-03-21T19:00:00Z', stat_type: 'points', book: 'draftkings', line: 26.5, over_odds: -110, under_odds: -110, fetched_at: '2026-03-21T12:00:00Z' }], })); axios.get.mockRejectedValue(new Error('API down')); const res = await request(app).get('/api/odds/nba').expect(200); expect(res.body.source).toBe('cache'); expect(res.headers['x-vyndr-stale']).toBe('true'); }); it('returns 503 when API fails and no cache', async () => { mockRedis.get.mockResolvedValue(null); axios.get.mockRejectedValue(new Error('API down')); const res = await request(app).get('/api/odds/nba').expect(503); expect(res.body.error).toBe('Odds service unavailable.'); }); }); describe('GET /api/odds/ncaab', () => { it('returns off-season message when NCAAB not in season', async () => { // Mock Date to July (off-season) const realDate = Date; const mockDate = new Date('2026-07-15T12:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => mockDate); Date.now = realDate.now; const res = await request(app).get('/api/odds/ncaab').expect(200); expect(res.body.props).toEqual([]); expect(res.body.message).toContain('off-season'); expect(res.body.source).toBe('none'); jest.restoreAllMocks(); }); });