183 lines
7.2 KiB
JavaScript
183 lines
7.2 KiB
JavaScript
// 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();
|
|
});
|
|
});
|
|
});
|