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