Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// Mock axios and the Redis cache surface BEFORE requiring the adapter so
|
||||
// jest's module-mock hoisting captures the calls.
|
||||
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/footballDataAdapter');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockCacheStore.clear();
|
||||
adapter.__internals.resetBucketForTests();
|
||||
});
|
||||
|
||||
describe('footballDataAdapter', () => {
|
||||
describe('graceful degradation when API key is missing', () => {
|
||||
const original = process.env.FOOTBALL_DATA_API_KEY;
|
||||
beforeAll(() => { delete process.env.FOOTBALL_DATA_API_KEY; });
|
||||
afterAll(() => { if (original !== undefined) process.env.FOOTBALL_DATA_API_KEY = original; });
|
||||
|
||||
test('hasApiKey reports false', () => {
|
||||
expect(adapter.hasApiKey()).toBe(false);
|
||||
});
|
||||
|
||||
test('getWorldCupFixtures returns null (does NOT hit axios)', async () => {
|
||||
const result = await adapter.getWorldCupFixtures();
|
||||
expect(result).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('getTeamSquad returns null (does NOT hit axios)', async () => {
|
||||
const result = await adapter.getTeamSquad(42);
|
||||
expect(result).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('happy path with API key configured', () => {
|
||||
beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; });
|
||||
|
||||
test('getLeagueFixtures projects API response to stable shape', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
matches: [
|
||||
{
|
||||
id: 1, homeTeam: { name: 'England' }, awayTeam: { name: 'Brazil' },
|
||||
utcDate: '2026-06-15T20:00:00Z', status: 'SCHEDULED',
|
||||
score: { winner: null }, matchday: 1, venue: 'MetLife Stadium',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(Array.isArray(fixtures)).toBe(true);
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(fixtures[0]).toMatchObject({
|
||||
id: 1, homeTeam: 'England', awayTeam: 'Brazil', status: 'SCHEDULED',
|
||||
matchday: 1, venue: 'MetLife Stadium', competition: 'WC',
|
||||
});
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
// Auth header carries the API key — never logged elsewhere.
|
||||
const [, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(opts.headers['X-Auth-Token']).toBe('test-key-123');
|
||||
});
|
||||
|
||||
test('second identical call serves from cache (axios not re-invoked)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 7 }] } });
|
||||
await adapter.getLeagueFixtures('PL');
|
||||
await adapter.getLeagueFixtures('PL');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('different competition codes use separate cache keys', async () => {
|
||||
mockAxiosGet
|
||||
.mockResolvedValueOnce({ data: { matches: [{ id: 1 }] } })
|
||||
.mockResolvedValueOnce({ data: { matches: [{ id: 2 }] } });
|
||||
await adapter.getLeagueFixtures('PL');
|
||||
await adapter.getLeagueFixtures('PD');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('getLeagueScorers projects to flat shape with goals + assists', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
scorers: [
|
||||
{
|
||||
player: { name: 'Harry Kane', position: 'Striker', nationality: 'England' },
|
||||
team: { name: 'England' },
|
||||
goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const scorers = await adapter.getLeagueScorers('WC');
|
||||
expect(scorers[0]).toMatchObject({
|
||||
name: 'Harry Kane', team: 'England', goals: 5, assists: 1,
|
||||
playedMatches: 4, minutesPlayed: 360,
|
||||
});
|
||||
});
|
||||
|
||||
test('getTeamSquad projects squad rows with position and shirt', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { squad: [{ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 }] },
|
||||
});
|
||||
const squad = await adapter.getTeamSquad(101);
|
||||
expect(squad[0]).toMatchObject({ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 });
|
||||
});
|
||||
|
||||
test('empty/missing arrays in upstream → empty list (not null)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: {} });
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(fixtures).toEqual([]);
|
||||
});
|
||||
|
||||
test('axios throw → returns null (graceful degradation)', async () => {
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('network down'));
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(fixtures).toBeNull();
|
||||
});
|
||||
|
||||
test('axios throw + prior :stale value → stale-while-revalidate', async () => {
|
||||
// Prime the stale cache.
|
||||
mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 999 }] });
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500'));
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
// The stale value goes through the same projection.
|
||||
expect(Array.isArray(fixtures)).toBe(true);
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(fixtures[0].id).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token bucket rate limiting', () => {
|
||||
beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; });
|
||||
|
||||
test('refuses network call when bucket is drained, falls to stale', async () => {
|
||||
// Drain the bucket by consuming all tokens.
|
||||
for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) {
|
||||
expect(adapter.__internals.tryConsumeToken()).toBe(true);
|
||||
}
|
||||
// Next consume should fail.
|
||||
expect(adapter.__internals.tryConsumeToken()).toBe(false);
|
||||
|
||||
// Prime a stale value.
|
||||
mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 42 }] });
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(fixtures[0].id).toBe(42);
|
||||
// Critically: axios was NOT called — the bucket short-circuited.
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns null when bucket drained AND no stale value', async () => {
|
||||
for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) {
|
||||
adapter.__internals.tryConsumeToken();
|
||||
}
|
||||
const fixtures = await adapter.getLeagueFixtures('UNKNOWN_LEAGUE');
|
||||
expect(fixtures).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('input guards', () => {
|
||||
test('getLeagueFixtures(null) returns null without touching network', async () => {
|
||||
const r = await adapter.getLeagueFixtures(null);
|
||||
expect(r).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
test('getTeamSquad(null) returns null without touching network', async () => {
|
||||
const r = await adapter.getTeamSquad(null);
|
||||
expect(r).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user