Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)

This commit is contained in:
Kev
2026-06-10 19:41:37 -04:00
parent 4db1c1c539
commit b55dcbd614
25 changed files with 2463 additions and 22 deletions
+201
View File
@@ -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();
});
});
});