process.env.ODDSPAPI_KEY = 'test-key'; process.env.ODDSPAPI_BASE_URL = 'https://api.oddspapi.test/v1'; const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args), })); // Fluent supabase fake. Records every upsert; from('grade_history') returns // configurable rows; from('closing_lines') accepts upserts. const mockGradeRows = { current: [] }; const mockUpserts = { current: [] }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => ({ from(table) { const ctx = { table }; const proxy = { select() { return proxy; }, eq() { return proxy; }, is() { return Promise.resolve({ data: mockGradeRows.current, error: null }); }, upsert(row) { mockUpserts.current.push({ table, row }); return Promise.resolve({ error: null }); }, }; return proxy; }, }), })); const adapter = require('../../src/services/adapters/oddsPapiAdapter'); beforeEach(() => { mockAxiosGet.mockReset(); mockGradeRows.current = []; mockUpserts.current = []; }); describe('oddsPapiAdapter.configured', () => { test('reflects ODDSPAPI_KEY presence', () => { expect(adapter.configured()).toBe(true); delete process.env.ODDSPAPI_KEY; expect(adapter.configured()).toBe(false); process.env.ODDSPAPI_KEY = 'test-key'; }); }); describe('getPinnacleClosingLine', () => { test('devigs Pinnacle prop into fair probabilities', async () => { mockAxiosGet.mockResolvedValue({ data: { props: [ { player: 'Jalen Brunson', stat_type: 'points', line: 26.5, over_price: -110, under_price: -110 }, ], }, }); const closing = await adapter.getPinnacleClosingLine('nba', 'g-1', '3934672', 'points', 'Jalen Brunson'); expect(closing).toMatchObject({ line: 26.5, overOdds: -110, underOdds: -110 }); expect(closing.fairOver).toBeCloseTo(0.5, 5); expect(typeof closing.capturedAt).toBe('string'); }); test('returns null when no matching prop', async () => { mockAxiosGet.mockResolvedValue({ data: { props: [] } }); const result = await adapter.getPinnacleClosingLine('nba', 'g-1', '3934672', 'points', 'Ghost'); expect(result).toBeNull(); }); test('returns null when unconfigured', async () => { delete process.env.ODDSPAPI_KEY; const result = await adapter.getPinnacleClosingLine('nba', 'g-1', 'x', 'points', 'Anyone'); expect(result).toBeNull(); process.env.ODDSPAPI_KEY = 'test-key'; }); }); describe('batchCapture', () => { test('skips when no graded props exist for the game', async () => { mockGradeRows.current = []; const summary = await adapter.batchCapture('nba', 'empty-game'); expect(summary).toMatchObject({ captured: 0, reason: 'no_graded_props' }); expect(mockUpserts.current).toHaveLength(0); }); test('upserts closing_lines for each unique (player, stat) pair', async () => { mockGradeRows.current = [ { player_id: '1', player_name: 'A. Player', stat_type: 'points' }, { player_id: '1', player_name: 'A. Player', stat_type: 'points' }, // duplicate { player_id: '2', player_name: 'B. Player', stat_type: 'rebounds' }, ]; mockAxiosGet.mockResolvedValue({ data: { props: [ { player: 'A. Player', stat_type: 'points', line: 22.5, over_price: -110, under_price: -110 }, { player: 'B. Player', stat_type: 'rebounds', line: 8.5, over_price: -105, under_price: -115 }, ], }, }); const summary = await adapter.batchCapture('nba', 'multi-game'); expect(summary.captured).toBe(2); expect(summary.total).toBe(2); // duplicates collapsed expect(mockUpserts.current).toHaveLength(2); expect(mockUpserts.current[0].table).toBe('closing_lines'); expect(mockUpserts.current[0].row).toMatchObject({ game_id: 'multi-game', sport: 'nba', stat_type: 'points', pinnacle_line: 22.5, }); }); test('marks props skipped when Pinnacle has no line for them', async () => { mockGradeRows.current = [{ player_id: '99', player_name: 'No Line', stat_type: 'points' }]; mockAxiosGet.mockResolvedValue({ data: { props: [] } }); const summary = await adapter.batchCapture('nba', 'no-pin'); expect(summary).toMatchObject({ captured: 0, skipped: 1, total: 1 }); }); });