// apiFootballAdapter — PRIMARY soccer source. Tests the auth header // shape (x-apisports-key, NOT RapidAPI), the rate-limit bookkeeping, // the graceful-degradation paths, and the per-endpoint projection. const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); 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 adapter = require('../../src/services/adapters/apiFootballAdapter'); beforeEach(async () => { mockAxiosGet.mockReset(); mockCacheStore.clear(); await adapter.__internals.resetCounterForTests(); // Re-clear so the counter set above doesn't persist into the next test. mockCacheStore.clear(); }); describe('apiFootballAdapter', () => { describe('graceful degradation when API_FOOTBALL_KEY missing', () => { const original = process.env.API_FOOTBALL_KEY; beforeAll(() => { delete process.env.API_FOOTBALL_KEY; }); afterAll(() => { if (original !== undefined) process.env.API_FOOTBALL_KEY = original; }); test('hasApiKey reports false', () => { expect(adapter.hasApiKey()).toBe(false); }); test('all endpoints return null without touching axios', async () => { expect(await adapter.getFixtures({ league: 1, season: 2026 })).toBeNull(); expect(await adapter.getFixtureLineups(42)).toBeNull(); expect(await adapter.getFixturePlayerStats(42)).toBeNull(); expect(await adapter.getFixtureEvents(42)).toBeNull(); expect(await adapter.getPlayerSeasonStats(100, 2026)).toBeNull(); expect(await adapter.getStandings(1, 2026)).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); describe('with key configured', () => { beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; }); test('auth header is x-apisports-key — NOT RapidAPI', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } }); await adapter.getFixtures({ league: 1, season: 2026 }); const [, opts] = mockAxiosGet.mock.calls[0]; expect(opts.headers['x-apisports-key']).toBe('test-apisports-key'); // RapidAPI headers must NOT be present. expect(opts.headers['x-rapidapi-key']).toBeUndefined(); expect(opts.headers['x-rapidapi-host']).toBeUndefined(); }); test('getFixtures projects to the unified shape', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [ { fixture: { id: 9001, date: '2026-06-11T20:00:00+00:00', status: { short: 'NS' }, venue: { name: 'Estadio Azteca' }, referee: 'Daniele Orsato' }, league: { name: 'World Cup', season: 2026, round: 'Group Stage - 1' }, teams: { home: { id: 26, name: 'Mexico' }, away: { id: 6, name: 'USA' } }, score: { fulltime: { home: null, away: null } }, }, ], }, }); const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); expect(fixtures).toHaveLength(1); expect(fixtures[0]).toMatchObject({ id: 9001, homeTeam: 'Mexico', awayTeam: 'USA', venue: 'Estadio Azteca', referee: 'Daniele Orsato', league: 'World Cup', }); }); test('getFixtures with no date works (whole season)', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } }); const fixtures = await adapter.getFixtures({ league: 1, season: 2026 }); expect(fixtures).toHaveLength(1); const [url] = mockAxiosGet.mock.calls[0]; expect(url).not.toMatch(/date=/); }); test('null params bounce without touching axios', async () => { expect(await adapter.getFixtures({})).toBeNull(); expect(await adapter.getFixtureLineups(null)).toBeNull(); expect(await adapter.getPlayerSeasonStats(null, 2026)).toBeNull(); expect(await adapter.getStandings(1, null)).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('getFixturePlayerStats flattens per-team rosters into one list', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [ { team: { name: 'Argentina' }, players: [ { player: { id: 1, name: 'Messi' }, statistics: [{ games: { minutes: 88, position: 'F', rating: '8.4', substitute: false }, goals: { total: 1, assists: 1, saves: null }, shots: { total: 5, on: 3 }, passes: { total: 47, accuracy: 89 }, tackles: { total: 1, blocks: 0, interceptions: 2 }, cards: { yellow: 0, red: 0 }, }], }, ], }, { team: { name: 'France' }, players: [ { player: { id: 2, name: 'Mbappe' }, statistics: [{ games: { minutes: 90, position: 'F', rating: '7.9', substitute: false }, goals: { total: 2, assists: 0, saves: null }, shots: { total: 7, on: 4 }, passes: { total: 28, accuracy: 71 }, tackles: { total: 0 }, cards: { yellow: 1, red: 0 }, }], }, ], }, ], }, }); const stats = await adapter.getFixturePlayerStats(9001); expect(stats).toHaveLength(2); const messi = stats.find((p) => p.name === 'Messi'); expect(messi.team).toBe('Argentina'); expect(messi.goals).toBe(1); expect(messi.shots_on).toBe(3); const mbappe = stats.find((p) => p.name === 'Mbappe'); expect(mbappe.team).toBe('France'); expect(mbappe.goals).toBe(2); expect(mbappe.yellow).toBe(1); }); test('getFixtureLineups projects formation + startXI + bench', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [ { team: { id: 26, name: 'Mexico' }, coach: { name: 'Some Coach' }, formation: '4-3-3', startXI: [ { player: { id: 10, name: 'GK', number: 1, pos: 'G', grid: '1:1' } }, ], substitutes: [{ player: { id: 11, name: 'Sub', number: 22, pos: 'M' } }], }, ], }, }); const lineups = await adapter.getFixtureLineups(9001); expect(lineups[0].formation).toBe('4-3-3'); expect(lineups[0].startXI[0].name).toBe('GK'); expect(lineups[0].substitutes[0].name).toBe('Sub'); }); test('getStandings flattens group standings into a flat list', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ league: { standings: [ [ // Group A { rank: 1, team: { id: 26, name: 'Mexico' }, all: { played: 3, win: 2, draw: 1, lose: 0, goals: { for: 5, against: 2 } }, points: 7, group: 'Group A' }, ], [ // Group B { rank: 1, team: { id: 5, name: 'Argentina' }, all: { played: 3, win: 3, draw: 0, lose: 0, goals: { for: 6, against: 1 } }, points: 9, group: 'Group B' }, ], ], }, }], }, }); const standings = await adapter.getStandings(1, 2026); expect(standings).toHaveLength(2); expect(standings.find((s) => s.team === 'Mexico')?.points).toBe(7); expect(standings.find((s) => s.team === 'Argentina')?.points).toBe(9); }); test('cache hit on repeat call (axios not re-invoked)', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } }); await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); expect(mockAxiosGet).toHaveBeenCalledTimes(1); }); test('axios throw → null + stale-while-revalidate', async () => { mockCacheStore.set('apifootball:fixtures:1:2026:2026-06-11:stale', { response: [{ fixture: { id: 999 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] }); mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500')); const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); expect(fixtures[0].id).toBe(999); }); test('axios throw with no stale → returns null', async () => { mockAxiosGet.mockRejectedValueOnce(new Error('network down')); const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); expect(fixtures).toBeNull(); }); }); describe('daily rate-limit accounting', () => { beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; }); test('bumps the counter on a successful network call', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } }); await adapter.getFixtures({ league: 1, season: 2026 }); const count = await adapter.__internals.readDailyCount(); expect(count).toBe(1); }); test('does NOT bump on cache hit', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } }); await adapter.getFixtures({ league: 1, season: 2026 }); await adapter.getFixtures({ league: 1, season: 2026 }); // cached const count = await adapter.__internals.readDailyCount(); expect(count).toBe(1); }); test('at SOFT_LIMIT, refuses network + serves stale if present', async () => { // Prime the counter to SOFT_LIMIT (90). const { cacheSet } = require('../../src/utils/redis'); await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT); mockCacheStore.set('apifootball:fixtures:1:2026:stale', { response: [{ fixture: { id: 7 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] }); const fixtures = await adapter.getFixtures({ league: 1, season: 2026 }); expect(fixtures[0].id).toBe(7); // Network NOT called — the bucket stopped us. expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('at SOFT_LIMIT with no stale → null', async () => { const { cacheSet } = require('../../src/utils/redis'); await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT); const fixtures = await adapter.getFixtures({ league: 1, season: 2026 }); expect(fixtures).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); });