const request = require('supertest'); // 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 (used by both oddsService and nbaStatsClient) jest.mock('axios'); const axios = require('axios'); process.env.ODDS_API_KEY = 'test-key'; process.env.NBA_SERVICE_URL = 'http://localhost:8000'; const app = require('../../src/app'); // Mock data const MOCK_ODDS_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_WITH_SPREADS = { ...MOCK_ODDS_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 }, ], }, { key: 'spreads', last_update: '2026-03-21T14:28:00Z', outcomes: [ { name: 'Denver Nuggets', price: -110, point: -5.5 }, { name: 'Los Angeles Lakers', price: -110, point: 5.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 MOCK_SEASON_AVG = { player: 'Nikola Jokic', player_id: 203999, team: 'DEN', season: '2025-26', source: 'cache', stats: { points: 26.3, rebounds: 12.4, assists: 9.1, threes: 1.1, blocks: 0.7, steals: 1.4, pra: 47.8, turnovers: 3.2, games_played: 65, minutes: 34.2, }, }; const MOCK_LAST_N = { player: 'Nikola Jokic', player_id: 203999, team: 'DEN', last_n: 10, source: 'cache', stats: { points: 28.1, rebounds: 13.0, assists: 10.2, threes: 1.3, blocks: 0.8, steals: 1.5, pra: 51.3, turnovers: 2.9, games_played: 10, minutes: 35.1, }, }; const MOCK_HOME_AWAY = { player: 'Nikola Jokic', stat_type: 'points', split_type: 'home_away', source: 'cache', splits: { home: { avg: 27.8, games: 33 }, away: { avg: 24.9, games: 32 }, }, }; const MOCK_REST_DAYS = { player: 'Nikola Jokic', stat_type: 'points', split_type: 'rest_days', source: 'cache', splits: { b2b: { avg: 23.1, games: 8 }, '1_day_rest': { avg: 26.5, games: 40 }, '2_plus_days_rest': { avg: 28.2, games: 17 }, }, }; const MOCK_VS_TEAM = { player: 'Nikola Jokic', stat_type: 'points', split_type: 'vs_team', opponent: 'LAL', source: 'cache', splits: { vs_opponent: { avg: 30.5, games: 3 }, vs_all_others: { avg: 25.8, games: 62 }, }, }; const API_HEADERS = { 'x-requests-remaining': '488', 'x-requests-used': '12', }; function setupMocks() { mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockRedis.hset.mockResolvedValue(1); mockRedis.hgetall.mockResolvedValue({}); mockRedis.expire.mockResolvedValue(1); // Odds API: events then event odds axios.get.mockImplementation((url) => { if (url.includes('/events') && !url.includes('/odds')) { return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS }); } if (url.includes('/odds')) { return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS }); } // NBA stats service calls if (url.includes('/stats/season-avg')) { return Promise.resolve({ data: MOCK_SEASON_AVG }); } if (url.includes('/stats/last-n')) { return Promise.resolve({ data: MOCK_LAST_N }); } if (url.includes('/stats/splits')) { if (url.includes('split_type=home_away') || (arguments[1]?.params?.split_type === 'home_away')) { return Promise.resolve({ data: MOCK_HOME_AWAY }); } if (url.includes('split_type=rest_days') || (arguments[1]?.params?.split_type === 'rest_days')) { return Promise.resolve({ data: MOCK_REST_DAYS }); } if (url.includes('split_type=vs_team') || (arguments[1]?.params?.split_type === 'vs_team')) { return Promise.resolve({ data: MOCK_VS_TEAM }); } return Promise.resolve({ data: MOCK_HOME_AWAY }); } return Promise.reject(new Error(`Unmocked URL: ${url}`)); }); } // Better mock that checks params function setupDetailedMocks() { mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockRedis.hset.mockResolvedValue(1); mockRedis.hgetall.mockResolvedValue({}); mockRedis.expire.mockResolvedValue(1); axios.get.mockImplementation((url, config) => { // Odds API if (url.includes('the-odds-api.com') && url.includes('/events') && !url.includes('/odds')) { return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS }); } if (url.includes('the-odds-api.com') && url.includes('/odds')) { return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS }); } // NBA stats service if (url.includes('localhost:8000/stats/season-avg')) { return Promise.resolve({ data: MOCK_SEASON_AVG }); } if (url.includes('localhost:8000/stats/last-n')) { return Promise.resolve({ data: MOCK_LAST_N }); } if (url.includes('localhost:8000/stats/splits')) { const splitType = config?.params?.split_type; if (splitType === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY }); if (splitType === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS }); if (splitType === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM }); return Promise.resolve({ data: MOCK_HOME_AWAY }); } if (url.includes('localhost:8000/players/search')) { return Promise.resolve({ data: { results: [{ player_id: 203999, full_name: 'Nikola Jokic' }] } }); } return Promise.reject(new Error(`Unmocked URL: ${url}`)); }); } beforeEach(() => { jest.clearAllMocks(); }); describe('POST /api/analyze/prop', () => { it('returns complete analysis with all fields', async () => { setupDetailedMocks(); const res = await request(app) .post('/api/analyze/prop') .send({ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings', }) .expect(200); expect(res.body.player).toBe('Nikola Jokic'); expect(res.body.stat_type).toBe('points'); expect(res.body.grade).toMatch(/^[ABCD]$/); expect(typeof res.body.edge_pct).toBe('number'); expect(typeof res.body.confidence).toBe('number'); expect(res.body.confidence).toBeGreaterThanOrEqual(30); expect(res.body.confidence).toBeLessThanOrEqual(95); expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true); expect(res.body.reasoning).toBeDefined(); expect(res.body.reasoning.summary).toBeDefined(); expect(res.body.reasoning.steps).toBeDefined(); expect(res.body.reasoning.steps.season_avg).toBeDefined(); expect(res.body.reasoning.steps.recent_form).toBeDefined(); expect(res.body.reasoning.steps.situational).toBeDefined(); expect(res.body.reasoning.steps.line_comparison).toBeDefined(); expect(res.body.reasoning.steps.kill_conditions).toBeDefined(); expect(res.body.reasoning.steps.final_grade).toBeDefined(); }); it('returns grade A/B for player averaging above line with good recent form', async () => { setupDetailedMocks(); const res = await request(app) .post('/api/analyze/prop') .send({ player: 'Nikola Jokic', stat_type: 'points', line: 24.5, direction: 'over', book: 'draftkings', }) .expect(200); expect(['A', 'B']).toContain(res.body.grade); expect(res.body.edge_pct).toBeGreaterThan(0); }); it('returns grade D for player averaging below line', async () => { setupDetailedMocks(); const res = await request(app) .post('/api/analyze/prop') .send({ player: 'Nikola Jokic', stat_type: 'points', line: 35.5, direction: 'over', book: 'draftkings', }) .expect(200); expect(res.body.grade).toBe('D'); }); it('caps grade when kill conditions trigger (blowout spread)', async () => { // Override spread to be a blowout const bigSpreadOdds = JSON.parse(JSON.stringify(MOCK_ODDS_WITH_SPREADS)); bigSpreadOdds.bookmakers[0].markets[1].outcomes[0].point = -15; bigSpreadOdds.bookmakers[0].markets[1].outcomes[1].point = 15; mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockRedis.hset.mockResolvedValue(1); mockRedis.hgetall.mockResolvedValue({}); mockRedis.expire.mockResolvedValue(1); axios.get.mockImplementation((url, config) => { if (url.includes('the-odds-api.com') && !url.includes('/odds')) { return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS }); } if (url.includes('the-odds-api.com') && url.includes('/odds')) { return Promise.resolve({ data: bigSpreadOdds, headers: API_HEADERS }); } if (url.includes('localhost:8000/stats/season-avg')) { return Promise.resolve({ data: MOCK_SEASON_AVG }); } if (url.includes('localhost:8000/stats/last-n')) { return Promise.resolve({ data: MOCK_LAST_N }); } if (url.includes('localhost:8000/stats/splits')) { const st = config?.params?.split_type; if (st === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY }); if (st === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS }); if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM }); return Promise.resolve({ data: MOCK_HOME_AWAY }); } return Promise.reject(new Error(`Unmocked: ${url}`)); }); const res = await request(app) .post('/api/analyze/prop') .send({ player: 'Nikola Jokic', stat_type: 'points', line: 24.5, direction: 'over', book: 'draftkings', }) .expect(200); const codes = res.body.kill_conditions_triggered.map((k) => k.code); expect(codes).toContain('blowout_risk'); expect(['C', 'D']).toContain(res.body.grade); }); it('returns 400 for missing player field', async () => { const res = await request(app) .post('/api/analyze/prop') .send({ stat_type: 'points', line: 26.5, direction: 'over' }) .expect(400); expect(res.body.error).toContain('player is required'); }); it('returns 400 for invalid stat_type', async () => { const res = await request(app) .post('/api/analyze/prop') .send({ player: 'Jokic', stat_type: 'invalid', line: 26.5, direction: 'over' }) .expect(400); expect(res.body.error).toContain('Invalid stat_type'); }); it('returns 400 for missing direction', async () => { const res = await request(app) .post('/api/analyze/prop') .send({ player: 'Jokic', stat_type: 'points', line: 26.5 }) .expect(400); expect(res.body.error).toContain('direction is required'); }); }); describe('POST /api/analyze/batch', () => { it('processes multiple props and returns array', async () => { setupDetailedMocks(); const res = await request(app) .post('/api/analyze/batch') .send({ props: [ { player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings' }, { player: 'Nikola Jokic', stat_type: 'rebounds', line: 12.5, direction: 'over', book: 'fanduel' }, ], }) .expect(200); expect(Array.isArray(res.body.results)).toBe(true); expect(res.body.results.length).toBe(2); }); it('returns 400 for empty props array', async () => { const res = await request(app) .post('/api/analyze/batch') .send({ props: [] }) .expect(400); expect(res.body.error).toContain('props array is required'); }); });