Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)

This commit is contained in:
Kev
2026-06-10 14:50:13 -04:00
parent b9084408bf
commit ad5ea8d5a8
28 changed files with 3175 additions and 49 deletions
+196
View File
@@ -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;
});
});
});