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