Files
vyndr/tests/unit/espnSummary.test.js
T
builtbykev fceb3707b5 Wave 2B: reliable cross-sport headshots via ESPN athlete index
The NBA/WNBA espnId was captured only from espnStatsAdapter (the offline-
Python fallback), unreliable in prod. Add espnAthleteIndex — a pure,
defensive harvester that builds { nameKey -> {espnId, headshotHref} } from
the ESPN schedule->summary/boxscore/leaders/injuries/roster feeds the
pipeline already calls (free, bounded mapLimit, cached, MLB->{}).

snapshotService now fills any player the primary stats-resolve left without
an espnId from this index, and stores a DIRECT headshotHref as headshotUrl
on the enriched grade (the exact URL, never 404s on a constructed path).
Threaded headshotUrl through slateAdapter.buildPlayerStripsFromProps ->
GameCard -> StatStrip -> PlayerAvatar/getHeadshotUrl (direct href wins over
the constructed one). MLB's MLBAM path is untouched. Soccer resolves only
via a direct href; absent -> honest monogram (API_FOOTBALL_KEY remains the
reliable soccer path, unwired).

getGameSummary now also passes through ESPN `rosters` (pre-game lineups
carry id + headshot). Everything graceful: any miss -> absent -> monogram.

Tests: tests/unit/espnHeadshotIndex.test.js (11) — fixture->index, snapshot
merge fallback, direct-href-wins, soccer honest monogram, malformed/cyclic
parse never throws. Full suite 3080 green; web next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:31 -04:00

77 lines
2.9 KiB
JavaScript

// Unit: ESPN summary enrichment (Session 30).
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...a) => mockAxiosGet(...a) }));
const mockStore = {};
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (k in mockStore ? mockStore[k] : null),
cacheSet: async (k, v) => { mockStore[k] = v; return true; },
}));
const { getGameSummary, __internals } = require('../../src/services/scheduleService');
beforeEach(() => {
mockAxiosGet.mockReset();
for (const k of Object.keys(mockStore)) delete mockStore[k];
});
describe('getGameSummary', () => {
test('extracts enriched fields for a valid sport + eventId', async () => {
mockAxiosGet.mockResolvedValue({
data: {
injuries: [{ team: 'CIN', injuries: [{ athlete: { displayName: 'Player X' }, status: 'OUT' }] }],
odds: [{ provider: { name: 'ESPN BET' }, spread: -1.5, overUnder: 9.5 }],
againstTheSpread: [{ team: { abbreviation: 'CIN' }, records: [] }],
leaders: [{ name: 'hits' }],
boxscore: { teams: [] },
},
});
const out = await getGameSummary('mlb', '401815722');
expect(out.injuries).toHaveLength(1);
expect(out.odds[0].overUnder).toBe(9.5);
expect(out.ats).not.toBeNull();
expect(out.boxscore).not.toBeNull();
const url = mockAxiosGet.mock.calls[0][0];
expect(url).toBe('https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/summary?event=401815722');
});
test('missing sections → empty defaults (no crash)', async () => {
mockAxiosGet.mockResolvedValue({ data: {} });
const out = await getGameSummary('nba', '999');
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null, rosters: [] });
});
test('invalid sport → empty defaults without axios', async () => {
const out = await getGameSummary('cricket', '1');
expect(out.injuries).toEqual([]);
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('missing eventId → empty defaults without axios', async () => {
await getGameSummary('mlb', null);
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('network error → empty defaults, not a throw', async () => {
mockAxiosGet.mockRejectedValue(new Error('espn down'));
const out = await getGameSummary('nba', '1');
expect(out.injuries).toEqual([]);
});
test('sport path mapping is correct', () => {
expect(__internals.ESPN_SPORT_PATHS.nba).toBe('basketball/nba');
expect(__internals.ESPN_SPORT_PATHS.mlb).toBe('baseball/mlb');
expect(__internals.ESPN_SPORT_PATHS.wnba).toBe('basketball/wnba');
expect(__internals.ESPN_SPORT_PATHS.nfl).toBe('football/nfl');
expect(__internals.ESPN_SPORT_PATHS.nhl).toBe('hockey/nhl');
});
test('caches — second call does not re-fetch', async () => {
mockAxiosGet.mockResolvedValue({ data: {} });
await getGameSummary('mlb', '5');
await getGameSummary('mlb', '5');
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
});
});