d10bb4cce2
Overnight sprint for the Saturday 10 AM ET deploy gate — day one of the
public ledger record locks against freshly posted lines.
Task A — ledger team/opponent (migration 020, applied at 0 rows):
populated in both write paths from the real feed; opponent only when the
player's team matches a game participant (never guessed). Roadmap: Phase
4.5 WNBA ESPN-boxscore settlement (due ~Jul 24) + Phase 5 per-tier
calibration logged.
Task B — work-order 1.6 CLOSED (canonical player keys):
- searchPlayer resolves via nameKey; the old matcher deleted accents
("Sanchez" with acute -> "snchez") and substring-guessed onto the WRONG
player (the mismatched last-10 bug). Ambiguous -> null, never guess.
- Slate JOIN INVARIANT: a graded prop whose player's real team isn't in
the game is dropped (TB player can't render under MIL@PIT) — locked by
tests that fail the suite on regression.
- grades:{sport} TTL 2h -> 6h (expired between 5h cron gaps — the real
cause of /team "No active props" for slate players).
Task C — Phase 2 slate UX: tabs are THE filter (URL ?sport=, deep-linkable,
duplicate legacy tablist removed); cards cap at 6 graded props sorted
A+->F with ALL N READS in-place expander; waiting states show the real
next pipeline run ("Grades post ~6:00 PM ET").
Task D — Phase 3 mobile P0: root cause of vanished 390px nav was HIDE_ON
including '/' (landing had zero navigation) — fixed; html/body overflow-x
contained; GAME LINES collapses to best-line summary + "N BOOKS" expander
below 640px; venue drops before time/pitchers ever truncate.
Live verification: raw ESPN today STILL returns the Jun 13 NYK@SA Finals
game without a date pin; the pinned fetch returns 0 games, 0 off-date.
Backend 2327 -> 2352 tests (202 suites), web build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
7.5 KiB
JavaScript
179 lines
7.5 KiB
JavaScript
// 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();
|
|
});
|
|
});
|