// Soccer poller — tests the tick() function (the unit of work). Run // loop intentionally not exercised: it's a sleep+repeat shape that // would only test setTimeout. const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); 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, })); // Stub the football-data adapter so soccer poller's "non-WC league" // branch is exercised without hitting the real API. const mockFbdGetLeagueFixtures = jest.fn(); const mockFbdGetWorldCupFixtures = jest.fn(); jest.mock('../../src/services/adapters/footballDataAdapter', () => ({ getLeagueFixtures: (...a) => mockFbdGetLeagueFixtures(...a), getWorldCupFixtures: (...a) => mockFbdGetWorldCupFixtures(...a), hasApiKey: () => false, })); const soccerPoller = require('../../poller/soccer'); beforeEach(() => { mockAxiosGet.mockReset(); mockFbdGetLeagueFixtures.mockReset(); mockFbdGetWorldCupFixtures.mockReset(); mockCacheSets.clear(); }); describe('soccer poller', () => { describe('parseLeagues', () => { test('defaults to WC when env var unset', () => { const original = process.env.SOCCER_LEAGUES; delete process.env.SOCCER_LEAGUES; expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC']); if (original !== undefined) process.env.SOCCER_LEAGUES = original; }); test('parses comma-separated list, uppercases, trims', () => { const original = process.env.SOCCER_LEAGUES; process.env.SOCCER_LEAGUES = 'wc, pl ,PD,bl1'; expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC', 'PL', 'PD', 'BL1']); if (original !== undefined) process.env.SOCCER_LEAGUES = original; else delete process.env.SOCCER_LEAGUES; }); }); describe('classifyStatus', () => { const { classifyStatus } = soccerPoller.__internals; test.each([ ['IN_PLAY', 'live'], ['PAUSED', 'live'], ['LIVE', 'live'], ['FINISHED', 'finished'], ['FINAL', 'finished'], ['COMPLETED', 'finished'], ['SCHEDULED', 'scheduled'], ['TIMED', 'scheduled'], ['', 'scheduled'], [null, 'scheduled'], ])('classifies %s → %s', (input, expected) => { expect(classifyStatus(input)).toBe(expected); }); }); describe('fetchWorldCupFixtures via OSS API', () => { test('projects the OSS API response to the unified shape', async () => { mockAxiosGet.mockResolvedValueOnce({ data: [ { id: 1, home_team: 'England', away_team: 'Brazil', utc_date: '2026-06-15T20:00:00Z', status: 'SCHEDULED', matchday: 1, venue: 'MetLife Stadium', }, ], }); const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures(); expect(Array.isArray(fixtures)).toBe(true); expect(fixtures[0]).toMatchObject({ id: 1, homeTeam: 'England', awayTeam: 'Brazil', venue: 'MetLife Stadium', competition: 'WC', }); }); test('axios throw → returns null (graceful)', async () => { mockAxiosGet.mockRejectedValueOnce(new Error('OSS down')); const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures(); expect(fixtures).toBeNull(); }); test('handles both top-level array and {matches: [...]} envelopes', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 9, homeTeam: 'X', awayTeam: 'Y' }] } }); const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures(); expect(fixtures).toHaveLength(1); expect(fixtures[0].id).toBe(9); }); }); describe('fetchLeagueFixtures dispatch', () => { test('WC prefers OSS API, falls back to football-data when OSS dies', async () => { mockAxiosGet.mockRejectedValueOnce(new Error('OSS unreachable')); mockFbdGetWorldCupFixtures.mockResolvedValueOnce([{ id: 1, homeTeam: 'A', awayTeam: 'B', competition: 'WC' }]); const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('WC'); expect(fixtures).toHaveLength(1); expect(mockFbdGetWorldCupFixtures).toHaveBeenCalledTimes(1); }); test('non-WC leagues use the football-data adapter', async () => { mockFbdGetLeagueFixtures.mockResolvedValueOnce([{ id: 7, homeTeam: 'X', awayTeam: 'Y', competition: 'PL' }]); const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('PL'); expect(fixtures).toHaveLength(1); expect(mockFbdGetLeagueFixtures).toHaveBeenCalledWith('PL'); }); }); describe('indexFixturesForLeague', () => { test('writes per-team nextmatch + lastfixture keys', async () => { const inFuture = new Date(Date.now() + 5 * 86_400_000).toISOString(); const inPast = new Date(Date.now() - 2 * 86_400_000).toISOString(); const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', [ { homeTeam: 'England', awayTeam: 'Brazil', utcDate: inFuture, status: 'SCHEDULED', venue: 'MetLife Stadium' }, { homeTeam: 'USA', awayTeam: 'Mexico', utcDate: inPast, status: 'FINISHED', venue: 'AT&T Stadium', score: { fullTime: { home: 2, away: 1 } } }, ]); expect(counts.scheduled).toBe(1); expect(counts.finished).toBe(1); // Future fixture → next match for both teams. expect(mockCacheSets.has('soccer:nextmatch:England')).toBe(true); expect(mockCacheSets.has('soccer:nextmatch:Brazil')).toBe(true); expect(mockCacheSets.get('soccer:nextmatch:England').value).toMatchObject({ opponent: 'Brazil', isHome: true, venue: 'MetLife Stadium', }); expect(mockCacheSets.get('soccer:nextmatch:Brazil').value).toMatchObject({ opponent: 'England', isHome: false, }); // Past finished → last fixture for both teams. expect(mockCacheSets.has('soccer:lastfixture:USA')).toBe(true); expect(mockCacheSets.has('soccer:lastfixture:Mexico')).toBe(true); }); test('returns zero counts on empty input', async () => { const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', []); expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 }); expect(mockCacheSets.size).toBe(0); }); test('returns zero counts on null input (graceful)', async () => { const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', null); expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 }); }); }); describe('tick', () => { test('tick polls each configured league and reports live status', async () => { const original = process.env.SOCCER_LEAGUES; process.env.SOCCER_LEAGUES = 'WC,PL'; const inFuture = new Date(Date.now() + 1 * 86_400_000).toISOString(); // WC: OSS returns one live match. mockAxiosGet.mockResolvedValueOnce({ data: [{ id: 1, home_team: 'A', away_team: 'B', utc_date: inFuture, status: 'IN_PLAY' }], }); // PL: football-data adapter returns one scheduled. mockFbdGetLeagueFixtures.mockResolvedValueOnce([ { id: 2, homeTeam: 'X', awayTeam: 'Y', utcDate: inFuture, status: 'SCHEDULED' }, ]); const result = await soccerPoller.tick(); expect(result.liveSeen).toBe(true); expect(result.summary.some((s) => s.startsWith('WC:'))).toBe(true); expect(result.summary.some((s) => s.startsWith('PL:'))).toBe(true); if (original !== undefined) process.env.SOCCER_LEAGUES = original; else delete process.env.SOCCER_LEAGUES; }); test('tick survives a league with no_data', async () => { const original = process.env.SOCCER_LEAGUES; process.env.SOCCER_LEAGUES = 'WC'; mockAxiosGet.mockRejectedValueOnce(new Error('OSS down')); mockFbdGetWorldCupFixtures.mockResolvedValueOnce(null); const result = await soccerPoller.tick(); expect(result.summary[0]).toMatch(/WC: no_data/); if (original !== undefined) process.env.SOCCER_LEAGUES = original; else delete process.env.SOCCER_LEAGUES; }); }); });