const { normalizeProps, MARKET_MAP, ALLOWED_BOOKS } = require('../../src/utils/oddsNormalizer'); function makeEvent(overrides = {}) { return { id: 'event-1', sport_key: 'basketball_nba', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z', bookmakers: [], ...overrides, }; } function makeBookmaker(key, markets) { return { key, title: key, markets }; } function makeMarket(marketKey, outcomes, lastUpdate = '2026-03-21T14:28:00Z') { return { key: marketKey, last_update: lastUpdate, outcomes }; } function makeOutcome(name, player, price, point) { return { name, description: player, price, point }; } describe('oddsNormalizer', () => { describe('normalizeProps', () => { it('normalizes a raw response with multiple books and markets', () => { const event = makeEvent({ bookmakers: [ makeBookmaker('draftkings', [ makeMarket('player_points', [ makeOutcome('Over', 'Nikola Jokic', -110, 26.5), makeOutcome('Under', 'Nikola Jokic', -110, 26.5), ]), ]), makeBookmaker('fanduel', [ makeMarket('player_points', [ makeOutcome('Over', 'Nikola Jokic', -105, 27.0), makeOutcome('Under', 'Nikola Jokic', -115, 27.0), ]), ]), ], }); const result = normalizeProps([event]); expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ player: 'Nikola Jokic', home_team: 'DEN', away_team: 'LAL', stat_type: 'points', book: 'draftkings', line: 26.5, over_odds: -110, under_odds: -110, }); expect(result[1]).toMatchObject({ player: 'Nikola Jokic', book: 'fanduel', line: 27.0, over_odds: -105, under_odds: -115, }); }); it('filters out books not in the allowed set', () => { const event = makeEvent({ bookmakers: [ makeBookmaker('bovada', [ makeMarket('player_points', [ makeOutcome('Over', 'Jokic', -110, 26.5), makeOutcome('Under', 'Jokic', -110, 26.5), ]), ]), makeBookmaker('draftkings', [ makeMarket('player_points', [ makeOutcome('Over', 'Jokic', -110, 26.5), makeOutcome('Under', 'Jokic', -110, 26.5), ]), ]), ], }); const result = normalizeProps([event]); expect(result).toHaveLength(1); expect(result[0].book).toBe('draftkings'); }); it('maps every market key to its internal stat_type (NBA + soccer)', () => { const markets = Object.entries(MARKET_MAP); const bookmaker = makeBookmaker( 'draftkings', markets.map(([key]) => makeMarket(key, [ makeOutcome('Over', 'Test Player', -110, 10.5), makeOutcome('Under', 'Test Player', -110, 10.5), ]) ) ); const event = makeEvent({ bookmakers: [bookmaker] }); const result = normalizeProps([event]); const statTypes = result.map((p) => p.stat_type); const expected = Object.values(MARKET_MAP); expect(statTypes).toEqual(expected); }); it('exposes the soccer market keys added in Session 7j', () => { // Sanity: soccer odds flow through the same normalizer as NBA. If a // future refactor splits MARKET_MAP per-sport, this test makes the // surface visible. const soccerStatTypes = ['goals', 'shots_on_target', 'shots', 'tackles', 'cards', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet']; const values = Object.values(MARKET_MAP); for (const t of soccerStatTypes) { expect(values).toContain(t); } }); it('exposes the NFL market keys added in the Session 31 audit', () => { // Defensive mapping landed before NFL is fully wired so it can't // repeat the MLB silent-zero bug. Both odds-api `_yds` and the // `_yards` spellings must resolve, and internal names align with // config/statFilters.js (passing/rushing/receiving_yards, interceptions). expect(MARKET_MAP.player_pass_yds).toBe('passing_yards'); expect(MARKET_MAP.player_pass_yards).toBe('passing_yards'); expect(MARKET_MAP.player_rush_yds).toBe('rushing_yards'); expect(MARKET_MAP.player_reception_yds).toBe('receiving_yards'); expect(MARKET_MAP.player_receiving_yards).toBe('receiving_yards'); expect(MARKET_MAP.player_receptions).toBe('receptions'); expect(MARKET_MAP.player_pass_interceptions).toBe('interceptions'); expect(MARKET_MAP.player_anytime_td).toBe('anytime_td'); // End-to-end: an NFL market normalizes to a real prop, not zero. const event = makeEvent({ bookmakers: [ makeBookmaker('draftkings', [ makeMarket('player_pass_yds', [ makeOutcome('Over', 'Patrick Mahomes', -110, 275.5), makeOutcome('Under', 'Patrick Mahomes', -110, 275.5), ]), ]), ], }); const result = normalizeProps([event]); expect(result).toHaveLength(1); expect(result[0].stat_type).toBe('passing_yards'); expect(result[0].player).toBe('Patrick Mahomes'); }); it('handles missing/null odds gracefully (skips incomplete outcomes)', () => { const event = makeEvent({ bookmakers: [ makeBookmaker('draftkings', [ makeMarket('player_points', [ // Missing description { name: 'Over', description: null, price: -110, point: 26.5 }, // Missing point { name: 'Over', description: 'Jokic', price: -110, point: null }, // Valid pair makeOutcome('Over', 'LeBron James', -110, 25.5), makeOutcome('Under', 'LeBron James', -110, 25.5), ]), ]), ], }); const result = normalizeProps([event]); expect(result).toHaveLength(1); expect(result[0].player).toBe('LeBron James'); }); it('returns empty array for empty input', () => { expect(normalizeProps([])).toEqual([]); }); it('returns empty array for events with no bookmakers', () => { const event = makeEvent({ bookmakers: undefined }); expect(normalizeProps([event])).toEqual([]); }); it('handles an outcome with only Over (no Under pair)', () => { const event = makeEvent({ bookmakers: [ makeBookmaker('fanduel', [ makeMarket('player_points', [ makeOutcome('Over', 'Solo Player', -110, 20.5), ]), ]), ], }); const result = normalizeProps([event]); expect(result).toHaveLength(1); expect(result[0].over_odds).toBe(-110); expect(result[0].under_odds).toBeNull(); }); it('uses UTC timestamps from the API as fetched_at', () => { const ts = '2026-03-21T18:00:00Z'; const event = makeEvent({ bookmakers: [ makeBookmaker('betmgm', [ makeMarket('player_points', [ makeOutcome('Over', 'Player A', -110, 10.5), makeOutcome('Under', 'Player A', -110, 10.5), ], ts), ]), ], }); const result = normalizeProps([event]); expect(result[0].fetched_at).toBe(ts); }); it('maps team names to 3-letter abbreviations', () => { const event = makeEvent({ home_team: 'Golden State Warriors', away_team: 'Phoenix Suns', bookmakers: [ makeBookmaker('draftkings', [ makeMarket('player_points', [ makeOutcome('Over', 'Steph Curry', -110, 28.5), makeOutcome('Under', 'Steph Curry', -110, 28.5), ]), ]), ], }); const result = normalizeProps([event]); expect(result[0].home_team).toBe('GSW'); expect(result[0].away_team).toBe('PHX'); }); }); });