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,196 @@
|
||||
// Soccer poller — tests the tick() function (the unit of work). Run
|
||||
// loop intentionally not exercised: it's a sleep+repeat shape that
|
||||
// would only test setTimeout.
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
||||
|
||||
const mockCacheSets = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; },
|
||||
cacheDel: async () => true,
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
// Stub the football-data adapter so soccer poller's "non-WC league"
|
||||
// branch is exercised without hitting the real API.
|
||||
const mockFbdGetLeagueFixtures = jest.fn();
|
||||
const mockFbdGetWorldCupFixtures = jest.fn();
|
||||
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
|
||||
getLeagueFixtures: (...a) => mockFbdGetLeagueFixtures(...a),
|
||||
getWorldCupFixtures: (...a) => mockFbdGetWorldCupFixtures(...a),
|
||||
hasApiKey: () => false,
|
||||
}));
|
||||
|
||||
const soccerPoller = require('../../poller/soccer');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockFbdGetLeagueFixtures.mockReset();
|
||||
mockFbdGetWorldCupFixtures.mockReset();
|
||||
mockCacheSets.clear();
|
||||
});
|
||||
|
||||
describe('soccer poller', () => {
|
||||
describe('parseLeagues', () => {
|
||||
test('defaults to WC when env var unset', () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
delete process.env.SOCCER_LEAGUES;
|
||||
expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC']);
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
});
|
||||
|
||||
test('parses comma-separated list, uppercases, trims', () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
process.env.SOCCER_LEAGUES = 'wc, pl ,PD,bl1';
|
||||
expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC', 'PL', 'PD', 'BL1']);
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
else delete process.env.SOCCER_LEAGUES;
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyStatus', () => {
|
||||
const { classifyStatus } = soccerPoller.__internals;
|
||||
test.each([
|
||||
['IN_PLAY', 'live'],
|
||||
['PAUSED', 'live'],
|
||||
['LIVE', 'live'],
|
||||
['FINISHED', 'finished'],
|
||||
['FINAL', 'finished'],
|
||||
['COMPLETED', 'finished'],
|
||||
['SCHEDULED', 'scheduled'],
|
||||
['TIMED', 'scheduled'],
|
||||
['', 'scheduled'],
|
||||
[null, 'scheduled'],
|
||||
])('classifies %s → %s', (input, expected) => {
|
||||
expect(classifyStatus(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWorldCupFixtures via OSS API', () => {
|
||||
test('projects the OSS API response to the unified shape', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: 1, home_team: 'England', away_team: 'Brazil',
|
||||
utc_date: '2026-06-15T20:00:00Z', status: 'SCHEDULED',
|
||||
matchday: 1, venue: 'MetLife Stadium',
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
|
||||
expect(Array.isArray(fixtures)).toBe(true);
|
||||
expect(fixtures[0]).toMatchObject({
|
||||
id: 1, homeTeam: 'England', awayTeam: 'Brazil',
|
||||
venue: 'MetLife Stadium', competition: 'WC',
|
||||
});
|
||||
});
|
||||
|
||||
test('axios throw → returns null (graceful)', async () => {
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('OSS down'));
|
||||
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
|
||||
expect(fixtures).toBeNull();
|
||||
});
|
||||
|
||||
test('handles both top-level array and {matches: [...]} envelopes', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 9, homeTeam: 'X', awayTeam: 'Y' }] } });
|
||||
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(fixtures[0].id).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLeagueFixtures dispatch', () => {
|
||||
test('WC prefers OSS API, falls back to football-data when OSS dies', async () => {
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('OSS unreachable'));
|
||||
mockFbdGetWorldCupFixtures.mockResolvedValueOnce([{ id: 1, homeTeam: 'A', awayTeam: 'B', competition: 'WC' }]);
|
||||
const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('WC');
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(mockFbdGetWorldCupFixtures).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('non-WC leagues use the football-data adapter', async () => {
|
||||
mockFbdGetLeagueFixtures.mockResolvedValueOnce([{ id: 7, homeTeam: 'X', awayTeam: 'Y', competition: 'PL' }]);
|
||||
const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('PL');
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(mockFbdGetLeagueFixtures).toHaveBeenCalledWith('PL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexFixturesForLeague', () => {
|
||||
test('writes per-team nextmatch + lastfixture keys', async () => {
|
||||
const inFuture = new Date(Date.now() + 5 * 86_400_000).toISOString();
|
||||
const inPast = new Date(Date.now() - 2 * 86_400_000).toISOString();
|
||||
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', [
|
||||
{ homeTeam: 'England', awayTeam: 'Brazil', utcDate: inFuture, status: 'SCHEDULED', venue: 'MetLife Stadium' },
|
||||
{ homeTeam: 'USA', awayTeam: 'Mexico', utcDate: inPast, status: 'FINISHED', venue: 'AT&T Stadium', score: { fullTime: { home: 2, away: 1 } } },
|
||||
]);
|
||||
expect(counts.scheduled).toBe(1);
|
||||
expect(counts.finished).toBe(1);
|
||||
|
||||
// Future fixture → next match for both teams.
|
||||
expect(mockCacheSets.has('soccer:nextmatch:England')).toBe(true);
|
||||
expect(mockCacheSets.has('soccer:nextmatch:Brazil')).toBe(true);
|
||||
expect(mockCacheSets.get('soccer:nextmatch:England').value).toMatchObject({
|
||||
opponent: 'Brazil', isHome: true, venue: 'MetLife Stadium',
|
||||
});
|
||||
expect(mockCacheSets.get('soccer:nextmatch:Brazil').value).toMatchObject({
|
||||
opponent: 'England', isHome: false,
|
||||
});
|
||||
|
||||
// Past finished → last fixture for both teams.
|
||||
expect(mockCacheSets.has('soccer:lastfixture:USA')).toBe(true);
|
||||
expect(mockCacheSets.has('soccer:lastfixture:Mexico')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns zero counts on empty input', async () => {
|
||||
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', []);
|
||||
expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 });
|
||||
expect(mockCacheSets.size).toBe(0);
|
||||
});
|
||||
|
||||
test('returns zero counts on null input (graceful)', async () => {
|
||||
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', null);
|
||||
expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick', () => {
|
||||
test('tick polls each configured league and reports live status', async () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
process.env.SOCCER_LEAGUES = 'WC,PL';
|
||||
const inFuture = new Date(Date.now() + 1 * 86_400_000).toISOString();
|
||||
|
||||
// WC: OSS returns one live match.
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: [{ id: 1, home_team: 'A', away_team: 'B', utc_date: inFuture, status: 'IN_PLAY' }],
|
||||
});
|
||||
// PL: football-data adapter returns one scheduled.
|
||||
mockFbdGetLeagueFixtures.mockResolvedValueOnce([
|
||||
{ id: 2, homeTeam: 'X', awayTeam: 'Y', utcDate: inFuture, status: 'SCHEDULED' },
|
||||
]);
|
||||
|
||||
const result = await soccerPoller.tick();
|
||||
expect(result.liveSeen).toBe(true);
|
||||
expect(result.summary.some((s) => s.startsWith('WC:'))).toBe(true);
|
||||
expect(result.summary.some((s) => s.startsWith('PL:'))).toBe(true);
|
||||
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
else delete process.env.SOCCER_LEAGUES;
|
||||
});
|
||||
|
||||
test('tick survives a league with no_data', async () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
process.env.SOCCER_LEAGUES = 'WC';
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('OSS down'));
|
||||
mockFbdGetWorldCupFixtures.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await soccerPoller.tick();
|
||||
expect(result.summary[0]).toMatch(/WC: no_data/);
|
||||
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
else delete process.env.SOCCER_LEAGUES;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user