Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user