// footApiAdapter — BACKUP soccer source via RapidAPI. Tests the // RapidAPI auth header shape, the per-endpoint projection, and the // graceful-degradation paths. 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/footApiAdapter'); beforeEach(async () => { mockAxiosGet.mockReset(); mockCacheStore.clear(); }); describe('footApiAdapter', () => { describe('graceful degradation when RAPID_API_KEY missing', () => { const original = process.env.RAPID_API_KEY; beforeAll(() => { delete process.env.RAPID_API_KEY; }); afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; }); test('hasApiKey reports false', () => { expect(adapter.hasApiKey()).toBe(false); }); test('all endpoints return null without touching axios', async () => { expect(await adapter.getMatchLineups(123)).toBeNull(); expect(await adapter.getMatchIncidents(123)).toBeNull(); expect(await adapter.getRefereeStatistics(7)).toBeNull(); expect(await adapter.getWorldCupSchedule(11, 6, 2026)).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); describe('with key configured', () => { beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; }); test('auth headers are RapidAPI shape — NOT x-apisports-key', async () => { mockAxiosGet.mockResolvedValueOnce({ data: {} }); await adapter.getMatchLineups(42); const [url, opts] = mockAxiosGet.mock.calls[0]; expect(url).toMatch(/^https:\/\/footapi7\.p\.rapidapi\.com/); expect(opts.headers['x-rapidapi-key']).toBe('test-rapid-key'); expect(opts.headers['x-rapidapi-host']).toBe('footapi7.p.rapidapi.com'); // Primary adapter's header MUST NOT appear. expect(opts.headers['x-apisports-key']).toBeUndefined(); }); test('FOOTAPI_HOST override is honored', async () => { const originalHost = process.env.FOOTAPI_HOST; process.env.FOOTAPI_HOST = 'mirror.rapidapi.com'; mockAxiosGet.mockResolvedValueOnce({ data: {} }); await adapter.getMatchLineups(7); const [url, opts] = mockAxiosGet.mock.calls[0]; expect(url).toMatch(/^https:\/\/mirror\.rapidapi\.com/); expect(opts.headers['x-rapidapi-host']).toBe('mirror.rapidapi.com'); if (originalHost !== undefined) process.env.FOOTAPI_HOST = originalHost; else delete process.env.FOOTAPI_HOST; }); test('getMatchLineups flattens home + away into one player list', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { home: { formation: '4-3-3', players: [ { player: { id: 1, name: 'Saka' }, position: 'F', shirtNumber: 7, statistics: { minutesPlayed: 88, rating: 8.1, goals: 1, goalAssist: 1, totalShots: 4, shotOnTarget: 2, totalPass: 35, accuratePass: 30, totalTackle: 2, yellowCards: 0, redCards: 0, keyPass: 3 }, }, ], }, away: { formation: '4-2-3-1', players: [ { player: { id: 2, name: 'Mbappe' }, position: 'F', substitute: false, statistics: { minutesPlayed: 90, rating: 7.7, goals: 0, totalShots: 5, shotOnTarget: 1, totalPass: 22, accuratePass: 18, yellowCards: 1 }, }, ], }, }, }); const lineups = await adapter.getMatchLineups(101); expect(lineups).toHaveLength(2); const saka = lineups.find((p) => p.name === 'Saka'); expect(saka.side).toBe('home'); expect(saka.goals).toBe(1); expect(saka.shotsOnTarget).toBe(2); expect(saka.assists).toBe(1); const mbappe = lineups.find((p) => p.name === 'Mbappe'); expect(mbappe.side).toBe('away'); expect(mbappe.yellow).toBe(1); }); test('getMatchIncidents projects time + addedTime + player + type', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { incidents: [ { incidentType: 'goal', time: 43, addedTime: 0, isHome: true, player: { name: 'A' }, assist1: { name: 'B' }, text: '1-0' }, { incidentType: 'card', incidentClass: 'yellow', time: 90, addedTime: 4, player: { name: 'C' } }, ], }, }); const events = await adapter.getMatchIncidents(101); expect(events).toHaveLength(2); expect(events[0]).toMatchObject({ type: 'goal', minute: 43, player: 'A', assist: 'B' }); expect(events[1]).toMatchObject({ type: 'card', minute: 90, addedTime: 4 }); }); test('getRefereeStatistics computes per-game rates', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { statistics: [ { tournament: { id: 16, name: 'World Cup' }, season: { year: 2022 }, appearances: 6, yellowCards: 24, redCards: 1 }, ], }, }); const refs = await adapter.getRefereeStatistics(99); expect(refs).toHaveLength(1); // 24/6 = 4.00 cards/game; 1/6 = ~0.167 red/game. expect(refs[0].yellowCardsPerGame).toBeCloseTo(4.0); expect(refs[0].redCardsPerGame).toBeCloseTo(0.167, 2); }); test('getRefereeStatistics handles zero appearances gracefully', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { statistics: [{ tournament: { id: 16 }, appearances: 0, yellowCards: 0, redCards: 0 }] }, }); const refs = await adapter.getRefereeStatistics(99); expect(refs[0].yellowCardsPerGame).toBeNull(); expect(refs[0].redCardsPerGame).toBeNull(); }); test('getWorldCupSchedule maps events with venue + referee', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { events: [ { id: 5, startTimestamp: 1749672000, status: { type: 'notstarted' }, homeTeam: { id: 26, name: 'Mexico' }, awayTeam: { id: 6, name: 'USA' }, homeScore: { current: 0 }, awayScore: { current: 0 }, venue: { name: 'Estadio Azteca' }, referee: { name: 'Daniele Orsato' }, }, ], }, }); const matches = await adapter.getWorldCupSchedule(11, 6, 2026); expect(matches[0]).toMatchObject({ id: 5, homeTeam: 'Mexico', awayTeam: 'USA', venue: 'Estadio Azteca', referee: 'Daniele Orsato', }); }); test('cache hit on repeat call (axios not re-invoked)', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { home: { players: [] }, away: { players: [] } } }); await adapter.getMatchLineups(101); await adapter.getMatchLineups(101); expect(mockAxiosGet).toHaveBeenCalledTimes(1); }); test('null IDs bounce without touching axios', async () => { expect(await adapter.getMatchLineups(null)).toBeNull(); expect(await adapter.getMatchIncidents(null)).toBeNull(); expect(await adapter.getRefereeStatistics(null)).toBeNull(); expect(await adapter.getWorldCupSchedule(null, 6, 2026)).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); describe('daily rate-limit (50 budget, soft 45)', () => { beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; }); beforeEach(async () => { await adapter.__internals.resetCounterForTests(); mockCacheStore.clear(); }); test('bumps counter on successful network call', async () => { mockAxiosGet.mockResolvedValueOnce({ data: {} }); await adapter.getMatchLineups(42); expect(await adapter.__internals.readDailyCount()).toBe(1); }); test('at SOFT_LIMIT refuses network + serves stale', async () => { const { cacheSet } = require('../../src/utils/redis'); await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT); mockCacheStore.set('footapi:match:42:lineups:stale', { home: { players: [{ player: { id: 1, name: 'Stale' }, statistics: {} }] }, away: { players: [] } }); const lineups = await adapter.getMatchLineups(42); expect(lineups[0].name).toBe('Stale'); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); });