// Resolver hardening — ESPN team-roster index is the PRIMARY name→id source // for NBA/WNBA (the v2 search is a backstop). Hermetic: fetch is injected, no // network. Verifies the index is COMPLETE (accented + suffix names fold), that // a roster-only player resolves (search never consulted needlessly), that the // roster hit WINS over a conflicting search result, that an unknown name never // guesses, and that a failing team fetch is skipped (partial index, no throw). // Redis no-op so cache read/write paths don't touch a live server. jest.mock('../../src/utils/redis', () => ({ cacheGet: async () => null, cacheSet: async () => {}, cacheDel: async () => {}, })); const espn = require('../../src/services/adapters/espnStatsAdapter'); // ── Fixtures ──────────────────────────────────────────────────────────────── const TEAMS = { sports: [{ leagues: [{ teams: [ { team: { id: '1', displayName: 'Minnesota Lynx' } }, { team: { id: '2', displayName: 'Atlanta Dream' } }, { team: { id: 'FAIL', displayName: 'Broken Team' } }, ] }] }], }; // Team 1 roster: the dual-league athlete the search missed + an accented name + // a suffix name (the two forms nameKey must fold to canonical keys). const ROSTER_1 = { athletes: [ { id: '3917450', displayName: 'Napheesa Collier', fullName: 'Napheesa Collier' }, { id: '999', displayName: 'José Álvarez', fullName: 'José Álvarez' }, { id: '555', displayName: 'Ronald Acuña Jr.', fullName: 'Ronald Acuña Jr.' }, { id: null, displayName: 'No Id Player' }, // skipped — no id { id: 'guid-xyz', displayName: 'Guid Only' }, // skipped — non-numeric id ], }; // Team 2 roster uses the GROUPED shape ([{ position, items:[…] }]). const ROSTER_2 = { athletes: [ { position: 'Guard', items: [{ id: '4066533', displayName: 'Sabrina Ionescu' }] }, { position: 'Forward', items: [{ id: '2998928', displayName: 'Breanna Stewart' }] }, ], }; const GAMELOG = { names: ['points', 'totalRebounds', 'assists'], seasonTypes: [{ categories: [{ events: [{ eventId: 'G1', stats: ['20', '9', '5'] }] }] }], events: { G1: { gameDate: '2026-07-13T01:00:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'IND' } } }, }; // A search payload that would resolve "Napheesa Collier" to a DIFFERENT // (wrong) id — proves the roster index is consulted first and wins. const SEARCH_CONFLICT = { results: [{ type: 'player', contents: [ { uid: 's:40~l:59~a:9999', displayName: 'Napheesa Collier', defaultLeagueSlug: 'wnba' }, ] }], }; const SEARCH_EMPTY = { results: [] }; // Route an injected fetch by URL. `/roster` matched BEFORE `/teams` (the roster // URL contains "/teams/"). Records whether search was ever hit. function makeFetch({ search = SEARCH_EMPTY, failTeam = 'FAIL' } = {}) { const calls = { search: 0, teams: 0, rosters: [] }; const impl = async (url) => { if (url.includes('/search/v2')) { calls.search++; return search; } const rosterMatch = /\/teams\/([^/]+)\/roster/.exec(url); if (rosterMatch) { const tid = rosterMatch[1]; calls.rosters.push(tid); if (tid === failTeam) throw new Error('roster fetch boom'); if (tid === '1') return ROSTER_1; if (tid === '2') return ROSTER_2; return { athletes: [] }; } if (url.endsWith('/teams')) { calls.teams++; return TEAMS; } if (url.includes('/gamelog')) return GAMELOG; return null; }; impl.calls = calls; return impl; } beforeEach(() => { espn.__internals.gameLogMem.clear(); espn.__internals.rosterMem.clear(); }); describe('buildAthleteRosterIndex', () => { it('aggregates all team rosters into a complete nameKey→{id,displayName,teamId} map', async () => { const fetchImpl = makeFetch(); const index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl }); // Dual-league athlete present. expect(index['napheesa collier']).toMatchObject({ id: '3917450', teamId: '1' }); // Accented name folds to an accent-stripped key. expect(index['jose alvarez']).toMatchObject({ id: '999', teamId: '1' }); // Suffix name folds (Jr. stripped) — display keeps the accent/original. expect(index['ronald acuna']).toMatchObject({ id: '555', displayName: 'Ronald Acuña Jr.' }); // Grouped-shape roster (team 2) flattened. expect(index['sabrina ionescu']).toMatchObject({ id: '4066533', teamId: '2' }); expect(index['breanna stewart']).toMatchObject({ id: '2998928', teamId: '2' }); }); it('skips athletes with no id or a non-numeric id (absent beats wrong)', async () => { const index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl: makeFetch() }); expect(index['no id player']).toBeUndefined(); expect(index['guid only']).toBeUndefined(); }); it('skips a team whose roster fetch fails — partial index, never throws', async () => { const fetchImpl = makeFetch(); // team 'FAIL' throws let index; await expect((async () => { index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl }); })()).resolves.toBeUndefined(); // The two healthy teams still populated the index. expect(index['napheesa collier']).toBeDefined(); expect(index['sabrina ionescu']).toBeDefined(); expect(fetchImpl.calls.rosters).toContain('FAIL'); }); it('returns {} for an unsupported sport without fetching', async () => { const fetchImpl = makeFetch(); expect(await espn.buildAthleteRosterIndex('nfl', { fetchImpl })).toEqual({}); expect(fetchImpl.calls.teams).toBe(0); }); }); describe('getPlayerGameLog — roster index is the primary resolver', () => { it('resolves a player found ONLY in the roster index (search returns nothing) → returns its gamelog', async () => { const fetchImpl = makeFetch({ search: SEARCH_EMPTY }); const res = await espn.getPlayerGameLog('Napheesa Collier', 'wnba', { fetchImpl }); expect(res.found).toBe(true); expect(res.id).toBe('3917450'); expect(res.last10[0].stat.points).toBe(20); }); it('tries the roster index BEFORE search — a roster hit wins over a conflicting search id', async () => { const fetchImpl = makeFetch({ search: SEARCH_CONFLICT }); const res = await espn.getPlayerGameLog('Napheesa Collier', 'wnba', { fetchImpl }); // Roster id (3917450), NOT the search's wrong 9999. expect(res.id).toBe('3917450'); // Search was never consulted — the roster resolved it. expect(fetchImpl.calls.search).toBe(0); }); it('a name in NEITHER roster nor search → { found:false } (never guesses another player)', async () => { const fetchImpl = makeFetch({ search: SEARCH_EMPTY }); const res = await espn.getPlayerGameLog('Ghost Player', 'wnba', { fetchImpl }); expect(res).toEqual({ found: false }); // Search WAS consulted as the backstop after the roster miss. expect(fetchImpl.calls.search).toBe(1); }); it('falls back to search when the roster index has no hit', async () => { // Sabrina is on team 2's roster, so instead query a name only search knows. const searchOnly = { results: [{ type: 'player', contents: [ { uid: 's:40~l:59~a:3149391', displayName: "A'ja Wilson", defaultLeagueSlug: 'wnba' }, ] }], }; const fetchImpl = makeFetch({ search: searchOnly }); const res = await espn.getPlayerGameLog("A'ja Wilson", 'wnba', { fetchImpl }); expect(res.found).toBe(true); expect(res.id).toBe('3149391'); expect(fetchImpl.calls.search).toBe(1); }); }); describe('resolveAthleteId ordering', () => { it('returns the roster id without ever calling search when the roster has the name', async () => { const fetchImpl = makeFetch({ search: SEARCH_CONFLICT }); const id = await espn.resolveAthleteId('Napheesa Collier', 'wnba', { fetchImpl }); expect(id).toBe('3917450'); expect(fetchImpl.calls.search).toBe(0); }); });