// Unit: MLB Stats API adapter (Session 30). No auth; cached; defensive. const mockAxiosGet = jest.fn(); jest.mock('axios', () => ({ get: (...a) => mockAxiosGet(...a) })); const mockStore = new Map(); const mockTtls = new Map(); jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => (mockStore.has(k) ? mockStore.get(k) : null), cacheSet: async (k, v, ttl) => { mockStore.set(k, v); mockTtls.set(k, ttl); return true; }, })); const adapter = require('../../src/services/adapters/mlbStatsAdapter'); const { TTL } = adapter.__internals; beforeEach(() => { mockAxiosGet.mockReset(); mockStore.clear(); mockTtls.clear(); }); describe('mlbStatsAdapter — no auth', () => { test('sends NO auth headers (free API)', async () => { mockAxiosGet.mockResolvedValue({ data: { dates: [] } }); await adapter.getScheduleWithPitchers('2026-06-14'); const [, opts] = mockAxiosGet.mock.calls[0]; expect(opts.headers).toBeUndefined(); }); }); describe('mlbStatsAdapter — schedule', () => { test('returns normalized games with probable pitchers', async () => { mockAxiosGet.mockResolvedValue({ data: { totalGames: 1, dates: [{ games: [{ gamePk: 777, gameDate: '2026-06-14T17:10:00Z', status: { abstractGameState: 'Preview' }, venue: { name: 'GABP' }, teams: { home: { team: { name: 'Reds', id: 17 }, probablePitcher: { id: 1, fullName: 'Hunter Greene' } }, away: { team: { name: 'D-backs', id: 29 }, probablePitcher: { id: 2, fullName: 'Zac Gallen' } }, }, }] }] }, }); const games = await adapter.getScheduleWithPitchers('2026-06-14'); expect(games).toHaveLength(1); expect(games[0].gamePk).toBe(777); expect(games[0].home.probablePitcher).toEqual({ id: 1, name: 'Hunter Greene' }); expect(games[0].away.team).toBe('D-backs'); expect(games[0].venue).toBe('GABP'); const url = mockAxiosGet.mock.calls[0][0]; expect(url).toContain('/schedule?sportId=1&date=2026-06-14'); expect(url).toContain('hydrate=probablePitcher'); expect(mockTtls.get('mlbstats:schedule:2026-06-14')).toBe(TTL.schedule); }); test('missing date → [] without axios', async () => { expect(await adapter.getScheduleWithPitchers()).toEqual([]); expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('error → [] (stale fallback empty)', async () => { mockAxiosGet.mockRejectedValue(new Error('mlb down')); expect(await adapter.getScheduleWithPitchers('2026-06-14')).toEqual([]); }); }); describe('mlbStatsAdapter — game log', () => { test('returns per-game splits', async () => { mockAxiosGet.mockResolvedValue({ data: { stats: [{ splits: [ { date: '2026-06-13', opponent: { name: 'Mets' }, isHome: true, stat: { hits: 2, homeRuns: 1 } }, { date: '2026-06-12', opponent: { name: 'Mets' }, isHome: true, stat: { hits: 0 } }, ] }] }, }); const log = await adapter.getPlayerGameLog(592450, 2026, 'hitting'); expect(log).toHaveLength(2); expect(log[0].stat.hits).toBe(2); expect(log[0].opponent).toBe('Mets'); const url = mockAxiosGet.mock.calls[0][0]; expect(url).toContain('/people/592450/stats?stats=gameLog&season=2026&group=hitting'); expect(mockTtls.get('mlbstats:gamelog:592450:2026:hitting')).toBe(TTL.gameLog); }); test('no playerId → []', async () => { expect(await adapter.getPlayerGameLog()).toEqual([]); }); }); describe('mlbStatsAdapter — season averages', () => { test('returns the season stat object', async () => { mockAxiosGet.mockResolvedValue({ data: { stats: [{ splits: [{ stat: { avg: '.312', obp: '.401', slg: '.589', ops: '.990', homeRuns: 22, rbi: 55 } }] }] } }); const s = await adapter.getSeasonAverages(592450); expect(s.avg).toBe('.312'); expect(s.slg).toBe('.589'); expect(mockTtls.get('mlbstats:season:592450:2026:hitting')).toBe(TTL.season); }); test('empty splits → null', async () => { mockAxiosGet.mockResolvedValue({ data: { stats: [] } }); expect(await adapter.getSeasonAverages(592450)).toBeNull(); }); }); describe('mlbStatsAdapter — batter vs pitcher', () => { test('returns matchup stat object', async () => { mockAxiosGet.mockResolvedValue({ data: { stats: [{ splits: [{ stat: { atBats: 14, hits: 5, homeRuns: 2, avg: '.357' } }] }] } }); const bvp = await adapter.getBatterVsPitcher(592450, 12345); expect(bvp.hits).toBe(5); expect(bvp.homeRuns).toBe(2); const url = mockAxiosGet.mock.calls[0][0]; expect(url).toContain('stats=vsPlayer&opposingPlayerId=12345'); expect(mockTtls.get('mlbstats:bvp:592450:12345:hitting')).toBe(TTL.bvp); }); test('missing ids → null without axios', async () => { expect(await adapter.getBatterVsPitcher(null, 1)).toBeNull(); expect(await adapter.getBatterVsPitcher(1, null)).toBeNull(); expect(mockAxiosGet).not.toHaveBeenCalled(); }); test('cache hit on repeat call', async () => { mockAxiosGet.mockResolvedValue({ data: { stats: [{ splits: [{ stat: { hits: 1 } }] }] } }); await adapter.getBatterVsPitcher(1, 2); await adapter.getBatterVsPitcher(1, 2); expect(mockAxiosGet).toHaveBeenCalledTimes(1); }); }); // Session 59 (work-order 1.6) — canonical player resolution. The old matcher // deleted accented letters ("Sánchez" → "snchez") and substring-guessed on a // miss, which could return ANOTHER player's id (→ wrong last-10 log). describe('searchPlayer — canonical nameKey resolution', () => { const PLAYERS = { data: { people: [ { id: 100, fullName: 'Cristopher Sanchez', currentTeam: { name: 'Philadelphia Phillies', id: 1 }, primaryPosition: { abbreviation: 'P' } }, { id: 200, fullName: 'Brandon Lowe', currentTeam: { name: 'Tampa Bay Rays', id: 2 }, primaryPosition: { abbreviation: '2B' } }, { id: 201, fullName: 'Nathaniel Lowe', currentTeam: { name: 'Texas Rangers', id: 3 }, primaryPosition: { abbreviation: '1B' } }, { id: 202, fullName: 'Josh Lowe', currentTeam: { name: 'Tampa Bay Rays', id: 2 }, primaryPosition: { abbreviation: 'OF' } }, { id: 300, fullName: 'Matthew Boyd', currentTeam: { name: 'Chicago Cubs', id: 4 }, primaryPosition: { abbreviation: 'P' } }, ], }, }; test('accented query resolves to the unaccented MLB record (Sánchez ≡ Sanchez)', async () => { mockAxiosGet.mockResolvedValue(PLAYERS); const hit = await adapter.searchPlayer('Cristopher Sánchez'); expect(hit).toBeTruthy(); expect(hit.id).toBe(100); }); test('never returns another player on a shared last name (Brandon ≠ Nathaniel Lowe)', async () => { mockAxiosGet.mockResolvedValue(PLAYERS); const hit = await adapter.searchPlayer('Brandon Lowe'); expect(hit.id).toBe(200); expect(hit.team).toBe('Tampa Bay Rays'); }); test('nickname resolves via nameKey (Matt Boyd → Matthew Boyd)', async () => { mockAxiosGet.mockResolvedValue(PLAYERS); const hit = await adapter.searchPlayer('Matt Boyd'); expect(hit && hit.id).toBe(300); }); test('ambiguous fallback returns null — a missing profile beats a wrong one', async () => { mockAxiosGet.mockResolvedValue({ data: { people: [ { id: 400, fullName: 'Jose Ramirez', currentTeam: { name: 'A' }, primaryPosition: { abbreviation: '3B' } }, { id: 401, fullName: 'Jorge Ramirez', currentTeam: { name: 'B' }, primaryPosition: { abbreviation: 'P' } }, ], }, }); // "J. Ramirez" collapses to first-initial J — TWO candidates → null. const hit = await adapter.searchPlayer('Jhon Ramirez'); expect(hit).toBeNull(); }); }); // Session 60 (night2/E, audit 4.1) — the scan box fuzzy matcher. describe('matchPlayers — fuzzy search (pure)', () => { const LIST = [ { id: 1, fullName: 'Shohei Ohtani', currentTeam: { name: 'Los Angeles Dodgers' }, primaryPosition: { abbreviation: 'DH' } }, { id: 2, fullName: 'Aaron Judge', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: 'RF' } }, { id: 3, fullName: 'Cristopher Sanchez', currentTeam: { name: 'Philadelphia Phillies' }, primaryPosition: { abbreviation: 'P' } }, { id: 4, fullName: 'Jazz Chisholm Jr.', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: '3B' } }, ]; const { matchPlayers } = adapter; test.each([ ['ohtani', 1], ['Aaron Judge', 2], ['Sánchez', 3], ['sanchez', 3], ['Chisholm Jr', 4], ['jazz chisholm', 4], ])('"%s" resolves', (q, id) => { const hits = matchPlayers(LIST, q); expect(hits.length).toBeGreaterThan(0); expect(hits[0].id).toBe(id); }); test('sub-2-char garbage resolves nothing', () => { expect(matchPlayers(LIST, '')).toEqual([]); }); });