// Mock axios and the Redis cache surface BEFORE requiring the adapter so // jest's module-mock hoisting captures the calls. 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/footballDataAdapter'); beforeEach(() => { mockAxiosGet.mockReset(); mockCacheStore.clear(); adapter.__internals.resetBucketForTests(); }); describe('footballDataAdapter', () => { describe('graceful degradation when API key is missing', () => { const original = process.env.FOOTBALL_DATA_API_KEY; beforeAll(() => { delete process.env.FOOTBALL_DATA_API_KEY; }); afterAll(() => { if (original !== undefined) process.env.FOOTBALL_DATA_API_KEY = original; }); test('hasApiKey reports false', () => { expect(adapter.hasApiKey()).toBe(false); }); test('getWorldCupFixtures returns null (does NOT hit axios)', async () => { const result = await adapter.getWorldCupFixtures(); expect(result).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('getTeamSquad returns null (does NOT hit axios)', async () => { const result = await adapter.getTeamSquad(42); expect(result).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); describe('happy path with API key configured', () => { beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; }); test('getLeagueFixtures projects API response to stable shape', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { matches: [ { id: 1, homeTeam: { name: 'England' }, awayTeam: { name: 'Brazil' }, utcDate: '2026-06-15T20:00:00Z', status: 'SCHEDULED', score: { winner: null }, matchday: 1, venue: 'MetLife Stadium', }, ], }, }); const fixtures = await adapter.getLeagueFixtures('WC'); expect(Array.isArray(fixtures)).toBe(true); expect(fixtures).toHaveLength(1); expect(fixtures[0]).toMatchObject({ id: 1, homeTeam: 'England', awayTeam: 'Brazil', status: 'SCHEDULED', matchday: 1, venue: 'MetLife Stadium', competition: 'WC', }); expect(mockAxiosGet).toHaveBeenCalledTimes(1); // Auth header carries the API key — never logged elsewhere. const [, opts] = mockAxiosGet.mock.calls[0]; expect(opts.headers['X-Auth-Token']).toBe('test-key-123'); }); test('second identical call serves from cache (axios not re-invoked)', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 7 }] } }); await adapter.getLeagueFixtures('PL'); await adapter.getLeagueFixtures('PL'); expect(mockAxiosGet).toHaveBeenCalledTimes(1); }); test('different competition codes use separate cache keys', async () => { mockAxiosGet .mockResolvedValueOnce({ data: { matches: [{ id: 1 }] } }) .mockResolvedValueOnce({ data: { matches: [{ id: 2 }] } }); await adapter.getLeagueFixtures('PL'); await adapter.getLeagueFixtures('PD'); expect(mockAxiosGet).toHaveBeenCalledTimes(2); }); test('getLeagueScorers projects to flat shape with goals + assists', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { scorers: [ { player: { name: 'Harry Kane', position: 'Striker', nationality: 'England' }, team: { name: 'England' }, goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360, }, ], }, }); const scorers = await adapter.getLeagueScorers('WC'); expect(scorers[0]).toMatchObject({ name: 'Harry Kane', team: 'England', goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360, }); }); test('getTeamSquad projects squad rows with position and shirt', async () => { mockAxiosGet.mockResolvedValueOnce({ data: { squad: [{ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 }] }, }); const squad = await adapter.getTeamSquad(101); expect(squad[0]).toMatchObject({ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 }); }); test('empty/missing arrays in upstream → empty list (not null)', async () => { mockAxiosGet.mockResolvedValueOnce({ data: {} }); const fixtures = await adapter.getLeagueFixtures('WC'); expect(fixtures).toEqual([]); }); test('axios throw → returns null (graceful degradation)', async () => { mockAxiosGet.mockRejectedValueOnce(new Error('network down')); const fixtures = await adapter.getLeagueFixtures('WC'); expect(fixtures).toBeNull(); }); test('axios throw + prior :stale value → stale-while-revalidate', async () => { // Prime the stale cache. mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 999 }] }); mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500')); const fixtures = await adapter.getLeagueFixtures('WC'); // The stale value goes through the same projection. expect(Array.isArray(fixtures)).toBe(true); expect(fixtures).toHaveLength(1); expect(fixtures[0].id).toBe(999); }); }); describe('token bucket rate limiting', () => { beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; }); test('refuses network call when bucket is drained, falls to stale', async () => { // Drain the bucket by consuming all tokens. for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) { expect(adapter.__internals.tryConsumeToken()).toBe(true); } // Next consume should fail. expect(adapter.__internals.tryConsumeToken()).toBe(false); // Prime a stale value. mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 42 }] }); const fixtures = await adapter.getLeagueFixtures('WC'); expect(fixtures[0].id).toBe(42); // Critically: axios was NOT called — the bucket short-circuited. expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('returns null when bucket drained AND no stale value', async () => { for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) { adapter.__internals.tryConsumeToken(); } const fixtures = await adapter.getLeagueFixtures('UNKNOWN_LEAGUE'); expect(fixtures).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); describe('input guards', () => { test('getLeagueFixtures(null) returns null without touching network', async () => { const r = await adapter.getLeagueFixtures(null); expect(r).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('getTeamSquad(null) returns null without touching network', async () => { const r = await adapter.getTeamSquad(null); expect(r).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); }); });