Files
vyndr/tests/unit/footballDataAdapter.test.js
T

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();
});
});
});