// Wave 1 trust bug — MLB namesake collision. Two different players can share an // EXACT nameKey ("James Wood": the Nationals star + a Cubs-affiliate namesake). // The old `.find` took the first → wrong currentTeam → wrong opponents → "built // vs AL East" fabrication in streaks/rosterlogs. Doctrine: never guess among // namesakes — resolve only with a confident team hint, else refuse (null). // // Hermetic: `searchPlayer` accepts injected `people`/`teams` fixtures — no // network, no redis. const mlb = require('../../src/services/adapters/mlbStatsAdapter'); const { teamRecordMatchesHint, disambiguateByHint, canonAbbr } = mlb.__internals; // statsapi-shaped fixtures. const JUDGE = { id: 592450, fullName: 'Aaron Judge', currentTeam: { id: 147, name: 'New York Yankees' }, primaryPosition: { abbreviation: 'RF' }, }; const WOOD_NATIONALS = { id: 691026, fullName: 'James Wood', currentTeam: { id: 120, name: 'Washington Nationals' }, primaryPosition: { abbreviation: 'LF' }, }; const WOOD_CUBS = { id: 999999, fullName: 'James Wood', currentTeam: { id: 112, name: 'Chicago Cubs' }, primaryPosition: { abbreviation: 'P' }, }; const SOTO_METS = { id: 665742, fullName: 'Juan Soto', currentTeam: { id: 121, name: 'New York Mets' }, primaryPosition: { abbreviation: 'RF' }, }; const TEAMS = [ { id: 147, abbr: 'NYY', name: 'New York Yankees' }, { id: 120, abbr: 'WSH', name: 'Washington Nationals' }, { id: 112, abbr: 'CHC', name: 'Chicago Cubs' }, { id: 146, abbr: 'MIA', name: 'Miami Marlins' }, { id: 121, abbr: 'NYM', name: 'New York Mets' }, { id: 119, abbr: 'LAD', name: 'Los Angeles Dodgers' }, { id: 135, abbr: 'SD', name: 'San Diego Padres' }, ]; describe('searchPlayer — namesake disambiguation (never guess)', () => { test('(a) a single unambiguous name resolves (Aaron Judge → Yankees)', async () => { const r = await mlb.searchPlayer('Aaron Judge', 2026, { people: [JUDGE], teams: TEAMS }); expect(r).not.toBeNull(); expect(r.id).toBe(592450); expect(r.team).toBe('New York Yankees'); }); test('(b) two "James Wood" with NO hint → null (NEVER the Cubs one)', async () => { const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_NATIONALS, WOOD_CUBS], teams: TEAMS }); expect(r).toBeNull(); // refuse — honest absent beats wrong }); test('(c) two "James Wood" + a Nationals-game hint → the Nationals Wood', async () => { const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_CUBS, WOOD_NATIONALS], // Cubs first — the old bug picked this teams: TEAMS, teamHint: ['WSH', 'MIA'], // Nationals @ Marlins }); expect(r).not.toBeNull(); expect(r.id).toBe(691026); expect(r.team).toBe('Washington Nationals'); expect(r.team).not.toBe('Chicago Cubs'); }); test('(c2) a full-team-name hint disambiguates too', async () => { const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_CUBS, WOOD_NATIONALS], teams: TEAMS, teamHint: ['Washington Nationals', 'Miami Marlins'], }); expect(r.id).toBe(691026); }); test('(c3) two namesakes + a hint matching NEITHER → null (still refuse)', async () => { const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_NATIONALS, WOOD_CUBS], teams: TEAMS, teamHint: ['LAD', 'SD'], }); expect(r).toBeNull(); }); }); // JOIN INVARIANT (streaks/rosterlogs) — a resolved player's team must be a // participant of the prop's game; on mismatch the team is DROPPED, never a // foreign tag. This is the mechanism snapshotService relies on before it writes // teamByPlayer + rosterlogs opponents. describe('searchPlayer — team join invariant (drop, never tag foreign)', () => { test('a single player whose team is NOT in the hinted game → team dropped to null', async () => { const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS, teamHint: ['LAD', 'SD'], // Soto (Mets) is in neither → cannot confirm join }); expect(r).not.toBeNull(); // still the right player by name expect(r.id).toBe(665742); expect(r.team).toBeNull(); // but his team is not tagged onto a foreign game expect(r.teamId).toBeNull(); }); test('a single player whose team IS in the hinted game keeps its team', async () => { const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS, teamHint: ['NYM', 'LAD'], // Mets @ Dodgers → confirmed }); expect(r.team).toBe('New York Mets'); }); test('with NO hint, a single player keeps its authoritative team', async () => { const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS }); expect(r.team).toBe('New York Mets'); }); }); describe('pure helpers — abbr reconciliation + hint matching', () => { test('ESPN↔statsapi abbr aliases canonicalize (AZ↔ARI, CHW↔CWS)', () => { expect(canonAbbr('AZ')).toBe(canonAbbr('ARI')); expect(canonAbbr('CHW')).toBe(canonAbbr('CWS')); expect(canonAbbr('nyy')).toBe('NYY'); }); test('teamRecordMatchesHint matches by abbr via the teams list', () => { expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['WSH'], TEAMS)).toBe(true); expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['CHC'], TEAMS)).toBe(false); }); test('teamRecordMatchesHint matches by partial name', () => { expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['Nationals'], TEAMS)).toBe(true); }); test('disambiguateByHint refuses when 2 candidates share the hinted team', () => { const dupe = [WOOD_NATIONALS, { ...WOOD_CUBS, currentTeam: { id: 120, name: 'Washington Nationals' } }]; expect(disambiguateByHint(dupe, ['WSH'], TEAMS)).toBeNull(); }); });