Files
vyndr/tests/unit/espnRosterIndex.test.js
T
builtbykev bbfce05b94 Resolver hardening: ESPN team-roster index as primary NBA/WNBA name→id source
Wave 0 shipped NBA/WNBA grading off ESPN per-athlete gamelogs, but name→id
resolution went only through the v2 /search endpoint, which is unreliable at
the edges. Live probing surfaced the real coverage gap: search actually
resolves the right id for most names, but dual-league athletes (WNBA + NCAA —
e.g. Napheesa Collier, Brionna Jones) get a filters-only gamelog until a
`?season=` is supplied, so they silently returned insufficient_data despite a
full season of games.

Two fixes:

1. buildAthleteRosterIndex(sport) — aggregates every team roster for nba/wnba
   into a complete { nameKey → {id, displayName, teamId} } map (canonical
   accent-folded keys via playerName.nameKey). Bounded concurrency (6) over the
   ~15-30 team fetches, Redis `espnroster:{sport}` (24h) + in-memory mirror,
   fully defensive (a failing team is skipped → partial index, never throws;
   grouped OR flat athletes[] shapes handled; non-numeric ids dropped). This is
   now the PRIMARY resolver in resolveAthleteId/getPlayerGameLog; the v2 search
   stays as a backstop on a roster miss. A unique roster hit wins (S59 doctrine)
   — a missing name beats guessing another player's id.

2. getPlayerGameLog retries the gamelog with candidate seasons (current +
   previous calendar year) ONLY when the first parse comes back empty
   (filters-only), unlocking the dual-league athletes. The common path is
   untouched.

MLB path (statsapi) unchanged; settlement/snapshot/frontend untouched.

Live probe: WNBA roster index = 206 players (Collier id 3917450 / Lynx team 8,
Brionna Jones id 3058895 present); NBA index = 544. Collier now resolves
end-to-end with 20 gamelog rows (was NOT FOUND); Brionna Jones likewise; all
previously-working players (A'ja Wilson, Ionescu, Clark, Stewart, Plum) still
resolve. NBA hyphen names (Gilgeous-Alexander) resolve via nameKey folding.

Tests: tests/unit/espnRosterIndex.test.js (9, fail-then-pass on base adapter).
Full backend suite 3183 green; web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:21:59 -04:00

176 lines
7.8 KiB
JavaScript

// 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);
});
});