// Mock Redis cache — populate per-test to simulate prefetched data. const mockCacheStore = new Map(); jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; }, cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, isDegraded: () => false, })); const { normalizeName } = require('../../src/utils/normalize'); const extractor = require('../../src/services/intelligence/soccerFeatureExtractor'); beforeEach(() => { mockCacheStore.clear(); }); function primePlayer(name, profile) { mockCacheStore.set(`soccer:player:${normalizeName(name)}`, profile); } describe('soccerFeatureExtractor', () => { describe('isSoccerSport', () => { test('accepts soccer + football, rejects nba/mlb/random', () => { expect(extractor.isSoccerSport('soccer')).toBe(true); expect(extractor.isSoccerSport('SOCCER')).toBe(true); expect(extractor.isSoccerSport('football')).toBe(true); expect(extractor.isSoccerSport('nba')).toBe(false); expect(extractor.isSoccerSport('mlb')).toBe(false); expect(extractor.isSoccerSport(null)).toBe(false); expect(extractor.isSoccerSport(undefined)).toBe(false); }); }); describe('cache-miss path (everything null)', () => { test('returns engine1-shaped result with errors flagged, no throw', async () => { const result = await extractor.extractSoccerFeatures({ player: 'Unknown Player', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(result).toHaveProperty('features'); expect(result).toHaveProperty('trap'); expect(result).toHaveProperty('consistency'); expect(result).toHaveProperty('prop'); expect(result).toHaveProperty('meta'); // Critical: numeric features default to null (NOT 0 — 0 would // confuse engine1 into thinking we have a real signal). expect(result.features.goals_per_90).toBeNull(); expect(result.features.xg_delta).toBeNull(); expect(result.features.minutes_per_game).toBeNull(); // Errors surface the misses. expect(result.meta.errors).toContain('player_not_found_in_cache'); expect(result.meta.errors).toContain('team_not_resolved'); }); test('does not throw on null player input — surfaces error', async () => { const result = await extractor.extractSoccerFeatures({ stat_type: 'goals', line: 0.5, }); expect(result.meta.errors).toEqual( expect.arrayContaining([expect.stringMatching(/missing required fields/)]) ); }); }); describe('player profile resolution', () => { test('reads cached profile by normalized name', async () => { primePlayer('Harry Kane', { team: 'England', goals_per_90: 0.85, assists_per_90: 0.15, minutes_per_game: 84, start_rate: 0.95, season_per_90: 0.78, recent_form_per_90: 1.05, }); const result = await extractor.extractSoccerFeatures({ player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(result.features.goals_per_90).toBe(0.85); expect(result.features.minutes_per_game).toBe(84); expect(result.features.start_rate).toBe(0.95); // engine1-canonical mapping: l5_avg from recent_form_per_90, // l20_avg from season_per_90. expect(result.features.l5_avg).toBe(1.05); expect(result.features.l20_avg).toBe(0.78); expect(result.meta.teamAbbr).toBe('England'); expect(result.meta.errors).not.toContain('player_not_found_in_cache'); }); }); describe('xG regression risk', () => { test('fires when actual goals significantly outpace expected', async () => { primePlayer('Striker A', { team: 'France', goals_per_90: 1.2, xg_per_90: 0.7, xg_delta: 0.71, }); const r = await extractor.extractSoccerFeatures({ player: 'Striker A', stat_type: 'goals', line: 1.5, direction: 'over', }); expect(r.features.xg_regression_risk).toBe(true); expect(r.features.xg_delta).toBeCloseTo(0.71); }); test('does NOT fire when xG and goals track each other', async () => { primePlayer('Striker B', { team: 'France', goals_per_90: 0.8, xg_per_90: 0.78, xg_delta: 0.025 }); const r = await extractor.extractSoccerFeatures({ player: 'Striker B', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.xg_regression_risk).toBe(false); }); test('does NOT fire when xG data is missing', async () => { primePlayer('Striker C', { team: 'France' }); const r = await extractor.extractSoccerFeatures({ player: 'Striker C', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.xg_regression_risk).toBe(false); }); }); describe('venue + altitude + home-continent overlay', () => { test('Estadio Azteca venue surfaces high altitude impact', async () => { primePlayer('Player X', { team: 'England', goals_per_90: 0.5 }); mockCacheStore.set('soccer:nextmatch:England', { opponent: 'USA', venue: 'Estadio Azteca', isHome: false, referee: 'Bjorn Kuipers', }); const r = await extractor.extractSoccerFeatures({ player: 'Player X', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.venue_altitude_ft).toBeGreaterThan(7000); expect(r.features.altitude_impact).toBe('high'); expect(r.features.home_continent).toBe(false); // England isn't CONCACAF expect(r.features.home_away).toBe(0.0); // away match }); test('USA at MetLife Stadium → home-continent true, altitude none', async () => { primePlayer('Christian Pulisic', { team: 'USA', goals_per_90: 0.3 }); mockCacheStore.set('soccer:nextmatch:USA', { opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: null, }); const r = await extractor.extractSoccerFeatures({ player: 'Christian Pulisic', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.home_continent).toBe(true); expect(r.features.altitude_impact).toBe('none'); expect(r.features.home_away).toBe(1.0); // Static-data lookup catches the penalty/corner role. expect(r.features.is_penalty_taker).toBe(true); }); }); describe('referee profile overlay', () => { test('reads cards/penalties per game for upcoming referee', async () => { primePlayer('Card Heavy', { team: 'Argentina', goals_per_90: 0.1 }); mockCacheStore.set('soccer:nextmatch:Argentina', { opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: 'Anthony Taylor', }); mockCacheStore.set('soccer:referee:Anthony Taylor', { cards_per_game: 5.4, penalties_per_game: 0.6, }); const r = await extractor.extractSoccerFeatures({ player: 'Card Heavy', stat_type: 'cards', line: 0.5, direction: 'over', }); expect(r.features.referee_cards_per_game).toBeCloseTo(5.4); expect(r.features.referee_penalties_per_game).toBeCloseTo(0.6); expect(r.features.referee_name).toBe('Anthony Taylor'); }); }); describe('rest_days from last fixture', () => { test('computes days since last finished fixture', async () => { primePlayer('Worn Out', { team: 'Brazil', goals_per_90: 0.6 }); const threeDaysAgo = new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString(); mockCacheStore.set('soccer:lastfixture:Brazil', { utcDate: threeDaysAgo }); const r = await extractor.extractSoccerFeatures({ player: 'Worn Out', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.rest_days).toBe(3); }); test('returns null when no last fixture cached', async () => { primePlayer('Fresh', { team: 'Croatia', goals_per_90: 0.4 }); const r = await extractor.extractSoccerFeatures({ player: 'Fresh', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.rest_days).toBeNull(); }); test('returns null for malformed utcDate', async () => { primePlayer('Edge Case', { team: 'Portugal', goals_per_90: 0.4 }); mockCacheStore.set('soccer:lastfixture:Portugal', { utcDate: 'not-a-date' }); const r = await extractor.extractSoccerFeatures({ player: 'Edge Case', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.rest_days).toBeNull(); }); }); describe('opponent defense overlay', () => { test('reads team defense aggregates from cache', async () => { primePlayer('Forward', { team: 'England', goals_per_90: 0.6 }); mockCacheStore.set('soccer:nextmatch:England', { opponent: 'Italy', venue: "Levi's Stadium", isHome: true, referee: null, }); mockCacheStore.set('soccer:teamdefense:wc:Italy', { goals_conceded_per_game: 0.4, clean_sheet_rate: 0.55, defensive_rank: 3, defensive_rank_norm: 0.05, // top defense }); const r = await extractor.extractSoccerFeatures({ player: 'Forward', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.opp_goals_conceded_per_game).toBeCloseTo(0.4); expect(r.features.opp_clean_sheet_rate).toBeCloseTo(0.55); expect(r.features.opp_rank_stat).toBeCloseTo(0.05); // engine1 reads this }); }); describe('tournament history overlay', () => { test('marks designated tournament players', async () => { primePlayer('Lionel Messi', { team: 'Argentina', goals_per_90: 0.8 }); const r = await extractor.extractSoccerFeatures({ player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.tournament_player).toBe(true); expect(r.features.wc_goals_career).toBeGreaterThan(0); }); test('non-tournament players get false', async () => { primePlayer('Rookie One', { team: 'USA', goals_per_90: 0.2 }); const r = await extractor.extractSoccerFeatures({ player: 'Rookie One', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.tournament_player).toBe(false); expect(r.features.wc_goals_career).toBeNull(); }); }); describe('shape compatibility with engine1', () => { test('returns the same top-level keys as the NBA path', async () => { primePlayer('Compat Test', { team: 'USA', goals_per_90: 0.3 }); const r = await extractor.extractSoccerFeatures({ player: 'Compat Test', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(Object.keys(r).sort()).toEqual( ['consistency', 'features', 'meta', 'prop', 'trap'].sort() ); // meta carries the same NBA-path field names so callers can read // teamAbbr / opponentAbbr / errors uniformly. expect(r.meta).toHaveProperty('teamAbbr'); expect(r.meta).toHaveProperty('opponentAbbr'); expect(r.meta).toHaveProperty('errors'); expect(r.meta).toHaveProperty('sport', 'soccer'); }); }); });