191 lines
7.8 KiB
JavaScript
191 lines
7.8 KiB
JavaScript
// Soccer daily prefetch — tests the data transforms + Redis writes via
|
|
// the cache-write spy. The football-data adapter is mocked at the
|
|
// module boundary so no network is touched.
|
|
|
|
const mockGetLeagueStandings = jest.fn();
|
|
const mockGetLeagueScorers = jest.fn();
|
|
const mockHasApiKey = jest.fn(() => true);
|
|
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
|
|
getLeagueStandings: (...a) => mockGetLeagueStandings(...a),
|
|
getLeagueScorers: (...a) => mockGetLeagueScorers(...a),
|
|
hasApiKey: (...a) => mockHasApiKey(...a),
|
|
}));
|
|
|
|
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,
|
|
}));
|
|
|
|
const { normalizeName } = require('../../src/utils/normalize');
|
|
const prefetch = require('../../scripts/soccer-data-prefetch');
|
|
|
|
beforeEach(() => {
|
|
mockGetLeagueStandings.mockReset();
|
|
mockGetLeagueScorers.mockReset();
|
|
mockHasApiKey.mockReset().mockReturnValue(true);
|
|
mockCacheSets.clear();
|
|
});
|
|
|
|
describe('soccer-data-prefetch', () => {
|
|
describe('parseArgs', () => {
|
|
test('default leagues=[WC]', () => {
|
|
const a = prefetch.__internals.parseArgs(['node', 'script']);
|
|
expect(a.leagues).toEqual(['WC']);
|
|
expect(a.dryRun).toBe(false);
|
|
});
|
|
test('--leagues=WC,PL,PD parsed and uppercased', () => {
|
|
const a = prefetch.__internals.parseArgs(['node', 'script', '--leagues=wc,pl,pd']);
|
|
expect(a.leagues).toEqual(['WC', 'PL', 'PD']);
|
|
});
|
|
test('--dry-run flag', () => {
|
|
const a = prefetch.__internals.parseArgs(['node', 'script', '--dry-run']);
|
|
expect(a.dryRun).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('aggregateTeamDefense', () => {
|
|
const { aggregateTeamDefense } = prefetch.__internals;
|
|
|
|
test('computes goals_conceded_per_game and rank from full table', () => {
|
|
const allRows = [
|
|
{ teamName: 'Italy', goalsAgainst: 3, playedGames: 10 }, // 0.30 — best
|
|
{ teamName: 'England', goalsAgainst: 6, playedGames: 10 }, // 0.60
|
|
{ teamName: 'France', goalsAgainst: 9, playedGames: 10 }, // 0.90 — worst
|
|
];
|
|
const italy = aggregateTeamDefense(allRows[0], allRows);
|
|
const england = aggregateTeamDefense(allRows[1], allRows);
|
|
const france = aggregateTeamDefense(allRows[2], allRows);
|
|
|
|
expect(italy.goals_conceded_per_game).toBeCloseTo(0.3);
|
|
expect(italy.defensive_rank).toBe(1);
|
|
expect(italy.defensive_rank_norm).toBeCloseTo(0);
|
|
|
|
expect(england.defensive_rank).toBe(2);
|
|
expect(england.defensive_rank_norm).toBeCloseTo(0.5);
|
|
|
|
expect(france.defensive_rank).toBe(3);
|
|
expect(france.defensive_rank_norm).toBeCloseTo(1);
|
|
});
|
|
|
|
test('returns null when row has no played games', () => {
|
|
const result = aggregateTeamDefense({ goalsAgainst: 0, playedGames: 0 }, []);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('clean_sheet_rate null when API does not provide it', () => {
|
|
const allRows = [{ goalsAgainst: 2, playedGames: 4 }];
|
|
const r = aggregateTeamDefense(allRows[0], allRows);
|
|
expect(r.clean_sheet_rate).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('aggregatePlayerFromScorer', () => {
|
|
const { aggregatePlayerFromScorer } = prefetch.__internals;
|
|
|
|
test('per-90 rates computed from minutes when present', () => {
|
|
const r = aggregatePlayerFromScorer({
|
|
name: 'Kane', team: 'England', goals: 3, assists: 1, playedMatches: 4, minutesPlayed: 360,
|
|
});
|
|
// 3 goals / (360/90) = 0.75 per 90.
|
|
expect(r.goals_per_90).toBeCloseTo(0.75);
|
|
expect(r.assists_per_90).toBeCloseTo(0.25);
|
|
expect(r.minutes_per_game).toBe(90);
|
|
});
|
|
|
|
test('per-90 falls back to per-match when minutes are missing', () => {
|
|
const r = aggregatePlayerFromScorer({
|
|
name: 'X', team: 'Y', goals: 4, assists: 2, playedMatches: 4, minutesPlayed: null,
|
|
});
|
|
// No minutes data → use goals/played as a rough proxy.
|
|
expect(r.goals_per_90).toBeCloseTo(1.0);
|
|
expect(r.minutes_per_game).toBeNull();
|
|
});
|
|
|
|
test('xG fields are explicitly null on Day 1', () => {
|
|
const r = aggregatePlayerFromScorer({ name: 'X', goals: 1, assists: 0, playedMatches: 2 });
|
|
expect(r.xg_per_90).toBeNull();
|
|
expect(r.xg_delta).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('processLeague — Redis writes', () => {
|
|
test('writes one teamdefense key per table row + one player key per scorer', async () => {
|
|
mockGetLeagueStandings.mockResolvedValueOnce([
|
|
{
|
|
type: 'TOTAL',
|
|
table: [
|
|
{ team: { name: 'Italy' }, goalsAgainst: 2, playedGames: 5 },
|
|
{ team: { name: 'France' }, goalsAgainst: 8, playedGames: 5 },
|
|
],
|
|
},
|
|
]);
|
|
mockGetLeagueScorers.mockResolvedValueOnce([
|
|
{ name: 'Harry Kane', team: 'England', goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360 },
|
|
{ name: 'Vinicius Junior', team: 'Brazil', goals: 3, assists: 2, playedMatches: 4, minutesPlayed: 340 },
|
|
]);
|
|
|
|
const summary = await prefetch.__internals.processLeague('WC', { dryRun: false });
|
|
|
|
expect(summary.standings).toBe(2);
|
|
expect(summary.scorers).toBe(2);
|
|
expect(summary.players).toBe(2);
|
|
expect(summary.teamDefense).toBe(2);
|
|
|
|
// Cache keys present at the right path.
|
|
expect(mockCacheSets.has('soccer:teamdefense:wc:Italy')).toBe(true);
|
|
expect(mockCacheSets.has('soccer:teamdefense:wc:France')).toBe(true);
|
|
expect(mockCacheSets.has(`soccer:player:${normalizeName('Harry Kane')}`)).toBe(true);
|
|
expect(mockCacheSets.has(`soccer:player:${normalizeName('Vinicius Junior')}`)).toBe(true);
|
|
expect(mockCacheSets.has('soccer:wc:standings')).toBe(true);
|
|
expect(mockCacheSets.has('soccer:wc:scorers')).toBe(true);
|
|
|
|
// TTLs match the constants.
|
|
const kaneEntry = mockCacheSets.get(`soccer:player:${normalizeName('Harry Kane')}`);
|
|
expect(kaneEntry.ttl).toBe(prefetch.__internals.PLAYER_TTL_SEC);
|
|
const italyEntry = mockCacheSets.get('soccer:teamdefense:wc:Italy');
|
|
expect(italyEntry.ttl).toBe(prefetch.__internals.DEFENSE_TTL_SEC);
|
|
});
|
|
|
|
test('dry-run computes summary but writes nothing', async () => {
|
|
mockGetLeagueStandings.mockResolvedValueOnce([
|
|
{ type: 'TOTAL', table: [{ team: { name: 'X' }, goalsAgainst: 1, playedGames: 1 }] },
|
|
]);
|
|
mockGetLeagueScorers.mockResolvedValueOnce([
|
|
{ name: 'X', team: 'X', goals: 1, assists: 0, playedMatches: 1, minutesPlayed: 90 },
|
|
]);
|
|
const summary = await prefetch.__internals.processLeague('WC', { dryRun: true });
|
|
expect(summary.players).toBeGreaterThan(0);
|
|
expect(mockCacheSets.size).toBe(0);
|
|
});
|
|
|
|
test('both API calls null → skipped flag', async () => {
|
|
mockGetLeagueStandings.mockResolvedValueOnce(null);
|
|
mockGetLeagueScorers.mockResolvedValueOnce(null);
|
|
const summary = await prefetch.__internals.processLeague('WC', { dryRun: false });
|
|
expect(summary.skipped).toBe(true);
|
|
expect(mockCacheSets.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('main — top-level entry', () => {
|
|
test('graceful skip when API key missing', async () => {
|
|
mockHasApiKey.mockReturnValueOnce(false);
|
|
const result = await prefetch.main(['node', 'script', '--leagues=WC']);
|
|
expect(result.skipped).toBe(true);
|
|
// Critically: adapter methods never invoked.
|
|
expect(mockGetLeagueStandings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('processes each --leagues arg in turn', async () => {
|
|
mockGetLeagueStandings.mockResolvedValue([]);
|
|
mockGetLeagueScorers.mockResolvedValue([]);
|
|
await prefetch.main(['node', 'script', '--leagues=WC,PL', '--dry-run']);
|
|
expect(mockGetLeagueStandings).toHaveBeenCalledWith('WC');
|
|
expect(mockGetLeagueStandings).toHaveBeenCalledWith('PL');
|
|
});
|
|
});
|
|
});
|