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 Supabase const mockSupabaseFrom = jest.fn(); const mockSupabaseAuth = { getUser: jest.fn(), }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), })); // Mock axios 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_USER_FREE = { id: 'user-free-1', email: 'free@test.com', tier: 'free', scan_count: 0, scan_reset_date: '2026-04-01', founder_status: false, }; const MOCK_USER_PAID = { id: 'user-paid-1', email: 'paid@test.com', tier: 'analyst', scan_count: 100, scan_reset_date: '2026-04-01', founder_status: true, }; 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_RESPONSE = { ...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 }, { 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: '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 }, ], }, ], }, ], }; 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_SPLITS = { splits: { home: { avg: 27.8, games: 33 }, away: { avg: 24.9, games: 32 } }, }; const MOCK_REST = { 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 = { 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 setupAllMocks(user = MOCK_USER_FREE) { // Auth mockSupabaseAuth.getUser.mockResolvedValue({ data: { user: { id: user.id, email: user.email } }, error: null, }); // Supabase from() calls mockSupabaseFrom.mockImplementation((table) => { if (table === 'users') { return { select: () => ({ eq: () => ({ single: () => Promise.resolve({ data: user, error: null }), }), }), update: () => ({ eq: (col, val) => ({ eq: () => ({ select: () => ({ single: () => Promise.resolve({ data: { scan_count: user.scan_count + 1 }, error: null }), }), }), }), }), }; } if (table === 'picks') { return { insert: () => ({ select: () => ({ single: () => Promise.resolve({ data: { id: 'pick-' + Math.random().toString(36).slice(2) }, error: null }), }), }), select: () => ({ eq: () => ({ order: () => ({ limit: () => Promise.resolve({ data: [], error: null }), }), }), }), }; } if (table === 'scan_sessions') { return { insert: () => ({ select: () => ({ single: () => Promise.resolve({ data: { id: 'session-1' }, error: null }), }), }), select: () => ({ eq: () => ({ order: () => ({ limit: () => Promise.resolve({ data: [], error: null }), }), }), }), }; } // Default for other tables (e.g., scan history queries in upgradePitch) return { select: () => ({ eq: () => ({ order: () => ({ limit: () => Promise.resolve({ data: [], error: null }), }), single: () => Promise.resolve({ data: null, error: null }), }), }), }; }); // Redis mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockRedis.hset.mockResolvedValue(1); mockRedis.hgetall.mockResolvedValue({}); mockRedis.expire.mockResolvedValue(1); // Axios (Odds API + NBA stats service) 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: MOCK_ODDS_RESPONSE, 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 === 'rest_days') return Promise.resolve({ data: MOCK_REST }); if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM }); return Promise.resolve({ data: MOCK_SPLITS }); } return Promise.reject(new Error(`Unmocked: ${url}`)); }); } const VALID_PARLAY = { legs: [ { player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings' }, { player: 'LeBron James', stat_type: 'rebounds', line: 8.5, direction: 'over', book: 'fanduel' }, ], }; beforeEach(() => { jest.clearAllMocks(); }); describe('POST /api/scan/parlay', () => { test('returns 401 without auth token', async () => { const res = await request(app) .post('/api/scan/parlay') .send(VALID_PARLAY) .expect(401); expect(res.body.error).toContain('Authentication required'); }); test('returns 401 with invalid token', async () => { mockSupabaseAuth.getUser.mockResolvedValue({ data: { user: null }, error: { message: 'invalid' } }); const res = await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer bad-token') .send(VALID_PARLAY) .expect(401); expect(res.body.error).toContain('Invalid'); }); test('returns 400 for fewer than 2 legs', async () => { setupAllMocks(); const res = await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer valid-token') .send({ legs: [{ player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over' }] }) .expect(400); expect(res.body.error).toContain('at least 2'); }); test('returns 422 for more than 12 legs', async () => { setupAllMocks(); const legs = Array(13).fill({ player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over' }); const res = await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer valid-token') .send({ legs }) .expect(422); expect(res.body.error).toContain('12 legs'); }); test('full scan: returns complete response with grades + correlations', async () => { setupAllMocks(); const res = await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer valid-token') .send(VALID_PARLAY) .expect(200); expect(res.body.scan_id).toBeDefined(); expect(res.body.parlay_grade).toMatch(/^[ABCD]$/); expect(typeof res.body.parlay_confidence).toBe('number'); expect(Array.isArray(res.body.correlation_flags)).toBe(true); expect(Array.isArray(res.body.legs)).toBe(true); expect(res.body.legs).toHaveLength(2); expect(res.body.legs[0]).toHaveProperty('grade'); expect(res.body.legs[0]).toHaveProperty('confidence'); expect(res.body.legs[0]).toHaveProperty('edge_pct'); expect(res.body.legs[0]).toHaveProperty('reasoning_summary'); expect(typeof res.body.scan_count).toBe('number'); }); test('paid user: unlimited scans, no pitch', async () => { setupAllMocks(MOCK_USER_PAID); const res = await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer valid-token') .send(VALID_PARLAY) .expect(200); expect(res.body.upgrade_pitch).toBeNull(); expect(res.body.scans_remaining).toBeNull(); }); test('scan 5: returns analysis WITH upgrade pitch', async () => { const user5 = { ...MOCK_USER_FREE, scan_count: 4 }; setupAllMocks(user5); const res = await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer valid-token') .send(VALID_PARLAY) .expect(200); expect(res.body.upgrade_pitch).not.toBeNull(); expect(res.body.upgrade_pitch.hook).toBeDefined(); expect(res.body.upgrade_pitch.cta).toContain('founder rate'); expect(res.body.upgrade_pitch.tier_recommended).toBeDefined(); expect(res.body.scans_remaining).toBe(0); }); test('scan 6+: returns 403 with upgrade pitch, no analysis', async () => { const user6 = { ...MOCK_USER_FREE, scan_count: 5 }; setupAllMocks(user6); const res = await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer valid-token') .send(VALID_PARLAY) .expect(403); expect(res.body.error).toBe('scan_limit_reached'); expect(res.body.upgrade_pitch).toBeDefined(); expect(res.body.upgrade_pitch.cta).toContain('founder rate'); // No legs in the response since analysis was blocked expect(res.body.legs).toBeUndefined(); }); test('database: picks and scan_sessions are written', async () => { setupAllMocks(); await request(app) .post('/api/scan/parlay') .set('Authorization', 'Bearer valid-token') .send(VALID_PARLAY) .expect(200); // Verify picks insert was called (2 legs = 2 picks) const pickInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'picks'); expect(pickInserts.length).toBe(2); // Verify scan_sessions insert was called once const sessionInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'scan_sessions'); expect(sessionInserts.length).toBeGreaterThanOrEqual(1); }); });