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
+256
View File
@@ -0,0 +1,256 @@
// 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();
});
});
});