// Soccer daily prefetch — tests the data transforms + Redis writes via // the cache-write spy. The football-data adapter is mocked at the // module boundary so no network is touched. const mockGetLeagueStandings = jest.fn(); const mockGetLeagueScorers = jest.fn(); const mockHasApiKey = jest.fn(() => true); jest.mock('../../src/services/adapters/footballDataAdapter', () => ({ getLeagueStandings: (...a) => mockGetLeagueStandings(...a), getLeagueScorers: (...a) => mockGetLeagueScorers(...a), hasApiKey: (...a) => mockHasApiKey(...a), })); const mockCacheSets = new Map(); jest.mock('../../src/utils/redis', () => ({ cacheGet: async () => null, cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; }, cacheDel: async () => true, isDegraded: () => false, })); const { normalizeName } = require('../../src/utils/normalize'); const prefetch = require('../../scripts/soccer-data-prefetch'); beforeEach(() => { mockGetLeagueStandings.mockReset(); mockGetLeagueScorers.mockReset(); mockHasApiKey.mockReset().mockReturnValue(true); mockCacheSets.clear(); }); describe('soccer-data-prefetch', () => { describe('parseArgs', () => { test('default leagues=[WC]', () => { const a = prefetch.__internals.parseArgs(['node', 'script']); expect(a.leagues).toEqual(['WC']); expect(a.dryRun).toBe(false); }); test('--leagues=WC,PL,PD parsed and uppercased', () => { const a = prefetch.__internals.parseArgs(['node', 'script', '--leagues=wc,pl,pd']); expect(a.leagues).toEqual(['WC', 'PL', 'PD']); }); test('--dry-run flag', () => { const a = prefetch.__internals.parseArgs(['node', 'script', '--dry-run']); expect(a.dryRun).toBe(true); }); }); describe('aggregateTeamDefense', () => { const { aggregateTeamDefense } = prefetch.__internals; test('computes goals_conceded_per_game and rank from full table', () => { const allRows = [ { teamName: 'Italy', goalsAgainst: 3, playedGames: 10 }, // 0.30 — best { teamName: 'England', goalsAgainst: 6, playedGames: 10 }, // 0.60 { teamName: 'France', goalsAgainst: 9, playedGames: 10 }, // 0.90 — worst ]; const italy = aggregateTeamDefense(allRows[0], allRows); const england = aggregateTeamDefense(allRows[1], allRows); const france = aggregateTeamDefense(allRows[2], allRows); expect(italy.goals_conceded_per_game).toBeCloseTo(0.3); expect(italy.defensive_rank).toBe(1); expect(italy.defensive_rank_norm).toBeCloseTo(0); expect(england.defensive_rank).toBe(2); expect(england.defensive_rank_norm).toBeCloseTo(0.5); expect(france.defensive_rank).toBe(3); expect(france.defensive_rank_norm).toBeCloseTo(1); }); test('returns null when row has no played games', () => { const result = aggregateTeamDefense({ goalsAgainst: 0, playedGames: 0 }, []); expect(result).toBeNull(); }); test('clean_sheet_rate null when API does not provide it', () => { const allRows = [{ goalsAgainst: 2, playedGames: 4 }]; const r = aggregateTeamDefense(allRows[0], allRows); expect(r.clean_sheet_rate).toBeNull(); }); }); describe('aggregatePlayerFromScorer', () => { const { aggregatePlayerFromScorer } = prefetch.__internals; test('per-90 rates computed from minutes when present', () => { const r = aggregatePlayerFromScorer({ name: 'Kane', team: 'England', goals: 3, assists: 1, playedMatches: 4, minutesPlayed: 360, }); // 3 goals / (360/90) = 0.75 per 90. expect(r.goals_per_90).toBeCloseTo(0.75); expect(r.assists_per_90).toBeCloseTo(0.25); expect(r.minutes_per_game).toBe(90); }); test('per-90 falls back to per-match when minutes are missing', () => { const r = aggregatePlayerFromScorer({ name: 'X', team: 'Y', goals: 4, assists: 2, playedMatches: 4, minutesPlayed: null, }); // No minutes data → use goals/played as a rough proxy. expect(r.goals_per_90).toBeCloseTo(1.0); expect(r.minutes_per_game).toBeNull(); }); test('xG fields are explicitly null on Day 1', () => { const r = aggregatePlayerFromScorer({ name: 'X', goals: 1, assists: 0, playedMatches: 2 }); expect(r.xg_per_90).toBeNull(); expect(r.xg_delta).toBeNull(); }); }); describe('processLeague — Redis writes', () => { test('writes one teamdefense key per table row + one player key per scorer', async () => { mockGetLeagueStandings.mockResolvedValueOnce([ { type: 'TOTAL', table: [ { team: { name: 'Italy' }, goalsAgainst: 2, playedGames: 5 }, { team: { name: 'France' }, goalsAgainst: 8, playedGames: 5 }, ], }, ]); mockGetLeagueScorers.mockResolvedValueOnce([ { name: 'Harry Kane', team: 'England', goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360 }, { name: 'Vinicius Junior', team: 'Brazil', goals: 3, assists: 2, playedMatches: 4, minutesPlayed: 340 }, ]); const summary = await prefetch.__internals.processLeague('WC', { dryRun: false }); expect(summary.standings).toBe(2); expect(summary.scorers).toBe(2); expect(summary.players).toBe(2); expect(summary.teamDefense).toBe(2); // Cache keys present at the right path. expect(mockCacheSets.has('soccer:teamdefense:wc:Italy')).toBe(true); expect(mockCacheSets.has('soccer:teamdefense:wc:France')).toBe(true); expect(mockCacheSets.has(`soccer:player:${normalizeName('Harry Kane')}`)).toBe(true); expect(mockCacheSets.has(`soccer:player:${normalizeName('Vinicius Junior')}`)).toBe(true); expect(mockCacheSets.has('soccer:wc:standings')).toBe(true); expect(mockCacheSets.has('soccer:wc:scorers')).toBe(true); // TTLs match the constants. const kaneEntry = mockCacheSets.get(`soccer:player:${normalizeName('Harry Kane')}`); expect(kaneEntry.ttl).toBe(prefetch.__internals.PLAYER_TTL_SEC); const italyEntry = mockCacheSets.get('soccer:teamdefense:wc:Italy'); expect(italyEntry.ttl).toBe(prefetch.__internals.DEFENSE_TTL_SEC); }); test('dry-run computes summary but writes nothing', async () => { mockGetLeagueStandings.mockResolvedValueOnce([ { type: 'TOTAL', table: [{ team: { name: 'X' }, goalsAgainst: 1, playedGames: 1 }] }, ]); mockGetLeagueScorers.mockResolvedValueOnce([ { name: 'X', team: 'X', goals: 1, assists: 0, playedMatches: 1, minutesPlayed: 90 }, ]); const summary = await prefetch.__internals.processLeague('WC', { dryRun: true }); expect(summary.players).toBeGreaterThan(0); expect(mockCacheSets.size).toBe(0); }); test('both API calls null → skipped flag', async () => { mockGetLeagueStandings.mockResolvedValueOnce(null); mockGetLeagueScorers.mockResolvedValueOnce(null); const summary = await prefetch.__internals.processLeague('WC', { dryRun: false }); expect(summary.skipped).toBe(true); expect(mockCacheSets.size).toBe(0); }); }); describe('main — top-level entry', () => { test('graceful skip when API key missing', async () => { mockHasApiKey.mockReturnValueOnce(false); const result = await prefetch.main(['node', 'script', '--leagues=WC']); expect(result.skipped).toBe(true); // Critically: adapter methods never invoked. expect(mockGetLeagueStandings).not.toHaveBeenCalled(); }); test('processes each --leagues arg in turn', async () => { mockGetLeagueStandings.mockResolvedValue([]); mockGetLeagueScorers.mockResolvedValue([]); await prefetch.main(['node', 'script', '--leagues=WC,PL', '--dry-run']); expect(mockGetLeagueStandings).toHaveBeenCalledWith('WC'); expect(mockGetLeagueStandings).toHaveBeenCalledWith('PL'); }); }); });