// Source-cascade tests for soccerFeatureExtractor (Session 9). The // pre-existing soccerFeatureExtractor.test.js covers the legacy // football-data path; this suite verifies that: // - api-football data wins when the prefetch alias exists // - footapi wins when api-football is missing but footapi alias exists // - football-data is still served when only the legacy key is set // - The `meta.sources` map attributes correctly per lookup // // We mock cacheGet to inspect which key the cascade asked for. 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(); }); describe('soccerFeatureExtractor — source cascade (Session 9)', () => { test('api-football wins when its alias is populated', async () => { const n = normalizeName('Lionel Messi'); // Only the apifootball alias is populated — others should not be consulted. mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'Argentina', goals_per_90: 0.92, minutes_per_game: 88, }); const r = await extractor.extractSoccerFeatures({ player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.goals_per_90).toBe(0.92); expect(r.meta.sources.player).toBe('api-football'); }); test('footapi wins when api-football is empty but footapi is populated', async () => { const n = normalizeName('Harry Kane'); mockCacheStore.set(`footapi:player_by_name:${n}`, { team: 'England', goals_per_90: 0.81, }); const r = await extractor.extractSoccerFeatures({ player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.goals_per_90).toBe(0.81); expect(r.meta.sources.player).toBe('footapi'); }); test('football-data legacy key is the final fallback', async () => { const n = normalizeName('Bukayo Saka'); mockCacheStore.set(`soccer:player:${n}`, { team: 'England', goals_per_90: 0.4, }); const r = await extractor.extractSoccerFeatures({ player: 'Bukayo Saka', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.features.goals_per_90).toBe(0.4); expect(r.meta.sources.player).toBe('football-data'); }); test('all-miss case → null source + errors populated', async () => { const r = await extractor.extractSoccerFeatures({ player: 'Unknown', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.meta.sources.player).toBeNull(); expect(r.meta.errors).toContain('player_not_found_in_cache'); }); test('nextMatch cascade — api-football preferred', async () => { const n = normalizeName('Vinicius Junior'); mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'Brazil' }); mockCacheStore.set('apifootball:nextmatch:Brazil', { opponent: 'Argentina', venue: 'MetLife Stadium', isHome: true, referee: 'A. Taylor', }); mockCacheStore.set('soccer:nextmatch:Brazil', { opponent: 'STALE', venue: 'old', isHome: false, referee: 'STALE', }); const r = await extractor.extractSoccerFeatures({ player: 'Vinicius Junior', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.meta.opponentAbbr).toBe('Argentina'); // api-football won expect(r.meta.sources.nextMatch).toBe('api-football'); }); test('referee cascade falls through to legacy key when richer sources empty', async () => { const n = normalizeName('Anyone'); mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'X' }); mockCacheStore.set('apifootball:nextmatch:X', { opponent: 'Y', venue: 'V', isHome: true, referee: 'Bjorn', }); mockCacheStore.set('soccer:referee:Bjorn', { cards_per_game: 4.2, penalties_per_game: 0.3, }); const r = await extractor.extractSoccerFeatures({ player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over', }); expect(r.features.referee_cards_per_game).toBe(4.2); expect(r.meta.sources.referee).toBe('football-data'); }); test('multiple sources active → independent attribution per lookup', async () => { const n = normalizeName('Mixed'); mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'France', goals_per_90: 1.1 }); // Match data only in legacy. mockCacheStore.set('soccer:nextmatch:France', { opponent: 'Italy', venue: 'AT&T Stadium', isHome: false, referee: 'X', }); // Referee only in footapi. mockCacheStore.set('footapi:referee_by_name:X', { cards_per_game: 5.5 }); const r = await extractor.extractSoccerFeatures({ player: 'Mixed', stat_type: 'goals', line: 0.5, direction: 'over', }); expect(r.meta.sources).toEqual({ player: 'api-football', nextMatch: 'football-data', lastFixture: null, referee: 'footapi', }); }); });