Session 25: Fix all data rendering — proxy routes, Tank01 normalizer, box-score bridge, inline streaks (1579 tests)
This commit is contained in:
@@ -30,12 +30,26 @@ function mountApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
// Session 25 — the REAL Tank01 shape (traced): sportsbooks are top-level
|
||||
// keys on the game object, alongside non-book keys (awayTeam, gameID…).
|
||||
const MLB_BODY = {
|
||||
'20260612_ARI@CIN': {
|
||||
gameID: '20260612_ARI@CIN',
|
||||
awayTeam: 'ARI',
|
||||
homeTeam: 'CIN',
|
||||
last_updated_e_time: '1718200000',
|
||||
bet365: { homeTeamML: '-110', awayTeamML: '+100', totalOver: '9.5', totalOverOdds: '-105', totalUnderOdds: '-115', homeTeamRunLine: '-1.5' },
|
||||
betmgm: { homeTeamML: '-115', awayTeamML: '-105', totalOver: '9' },
|
||||
caesars: { homeTeamML: '-112', awayTeamML: '-102', totalUnder: '9.5' },
|
||||
},
|
||||
};
|
||||
|
||||
// Legacy array shape — still supported for backward compatibility.
|
||||
const MLB_BODY_LEGACY = {
|
||||
'20260612_ARI@CIN': {
|
||||
gameID: '20260612_ARI@CIN',
|
||||
sportsBooks: [
|
||||
{ sportsBook: 'bet365', odds: { homeTeamMLOdds: '-110', awayTeamMLOdds: '+100', totalOver: '9.5', totalOverOdds: '-105', totalUnderOdds: '-115' } },
|
||||
{ sportsBook: 'betmgm', odds: { homeTeamMLOdds: '-115', awayTeamMLOdds: '-105', totalOver: '9' } },
|
||||
{ sportsBook: 'bet365', odds: { homeTeamMLOdds: '-110', awayTeamMLOdds: '+100', totalOver: '9.5' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -49,7 +63,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('GET /api/gamelines/:sport', () => {
|
||||
test('mlb returns book-by-book odds with teams parsed from gameID', async () => {
|
||||
test('mlb returns book-by-book odds from top-level book keys (real shape)', async () => {
|
||||
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
|
||||
const res = await request(mountApp()).get('/api/gamelines/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
@@ -57,9 +71,38 @@ describe('GET /api/gamelines/:sport', () => {
|
||||
const game = res.body.games['20260612_ARI@CIN'];
|
||||
expect(game.homeTeam).toBe('CIN');
|
||||
expect(game.awayTeam).toBe('ARI');
|
||||
// Books must be POPULATED (the Session 25 bug: this was {}).
|
||||
expect(Object.keys(game.books).sort()).toEqual(['bet365', 'betmgm', 'caesars']);
|
||||
expect(game.books.bet365.homeML).toBe('-110');
|
||||
expect(game.books.bet365.awayML).toBe('+100');
|
||||
expect(game.books.bet365.total).toBe('9.5');
|
||||
expect(game.books.bet365.homeSpread).toBe('-1.5');
|
||||
expect(game.books.betmgm.homeML).toBe('-115');
|
||||
expect(game.books.caesars.total).toBe('9.5'); // totalUnder fallback
|
||||
});
|
||||
|
||||
test('non-book keys (awayTeam, homeTeam, gameID) are excluded from books', async () => {
|
||||
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
|
||||
const res = await request(mountApp()).get('/api/gamelines/mlb');
|
||||
const books = res.body.games['20260612_ARI@CIN'].books;
|
||||
expect(books.awayteam).toBeUndefined();
|
||||
expect(books.hometeam).toBeUndefined();
|
||||
expect(books.gameid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('missing fields normalize to null, never undefined/crash', async () => {
|
||||
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
|
||||
const res = await request(mountApp()).get('/api/gamelines/mlb');
|
||||
const betmgm = res.body.games['20260612_ARI@CIN'].books.betmgm;
|
||||
expect(betmgm.homeSpread).toBeNull();
|
||||
expect(betmgm.overOdds).toBeNull();
|
||||
expect(Object.prototype.hasOwnProperty.call(betmgm, 'homeSpread')).toBe(true);
|
||||
});
|
||||
|
||||
test('legacy sportsBooks array shape still normalizes', async () => {
|
||||
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY_LEGACY);
|
||||
const res = await request(mountApp()).get('/api/gamelines/mlb');
|
||||
expect(res.body.games['20260612_ARI@CIN'].books.bet365.homeML).toBe('-110');
|
||||
});
|
||||
|
||||
test('nba returns empty games object off-season (not an error)', async () => {
|
||||
@@ -89,7 +132,7 @@ describe('GET /api/gamelines/:sport', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('cache works — adapter called once per request (adapter owns TTL)', async () => {
|
||||
test('adapter called once per request (adapter owns TTL)', async () => {
|
||||
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
|
||||
const app = mountApp();
|
||||
await request(app).get('/api/gamelines/mlb');
|
||||
|
||||
@@ -58,3 +58,78 @@ describe('rosterLogs', () => {
|
||||
expect(__internals.playerFromKey('gamelogs:nba:LeBron James:20', 'nba')).toBe('LeBron James');
|
||||
});
|
||||
});
|
||||
|
||||
// Session 25 — box-score aggregation bridge (prefetch key alignment).
|
||||
describe('rosterLogs — Tank01 box-score aggregation', () => {
|
||||
// Route scans by their MATCH pattern (3rd arg) so gamelogs + boxscore
|
||||
// scans can return different key sets.
|
||||
function routeScan(map) {
|
||||
mockScan.mockImplementation(async (_cursor, _m, match) => ['0', map[match] || []]);
|
||||
}
|
||||
|
||||
test('aggregates MLB box scores into per-player multi-game logs', async () => {
|
||||
routeScan({
|
||||
'gamelogs:mlb:*': [],
|
||||
'tank01:mlb:boxscore:*': [
|
||||
'tank01:mlb:boxscore:20260612_ARI@CIN',
|
||||
'tank01:mlb:boxscore:20260611_ARI@LAD',
|
||||
],
|
||||
});
|
||||
// Newer game (0612) and older game (0611) for the same batter.
|
||||
store['tank01:mlb:boxscore:20260612_ARI@CIN'] = [
|
||||
{ role: 'batter', name: 'Acuna', playerId: 'B1', team: 'ARI', _raw: { H: 2, HR: 1 }, _final: true },
|
||||
];
|
||||
store['tank01:mlb:boxscore:20260611_ARI@LAD'] = [
|
||||
{ role: 'batter', name: 'Acuna', playerId: 'B1', team: 'ARI', _raw: { H: 1, HR: 0 }, _final: true },
|
||||
];
|
||||
const roster = await loadRosterLogs('mlb');
|
||||
expect(roster).toHaveLength(1);
|
||||
const acuna = roster[0];
|
||||
expect(acuna.name).toBe('Acuna');
|
||||
expect(acuna.team).toBe('ARI');
|
||||
expect(acuna.games).toHaveLength(2);
|
||||
// Most-recent first → the 2-hit game leads (flattened from _raw).
|
||||
expect(acuna.games[0].H).toBe(2);
|
||||
expect(acuna.games[1].H).toBe(1);
|
||||
});
|
||||
|
||||
test('aggregated MLB logs feed the streaks engine (hit streak)', async () => {
|
||||
routeScan({
|
||||
'gamelogs:mlb:*': [],
|
||||
'tank01:mlb:boxscore:*': ['tank01:mlb:boxscore:20260612_A@B', 'tank01:mlb:boxscore:20260611_A@B'],
|
||||
});
|
||||
store['tank01:mlb:boxscore:20260612_A@B'] = [{ role: 'batter', name: 'X', team: 'A', _raw: { H: 2 }, _final: true }];
|
||||
store['tank01:mlb:boxscore:20260611_A@B'] = [{ role: 'batter', name: 'X', team: 'A', _raw: { H: 1 }, _final: true }];
|
||||
const roster = await loadRosterLogs('mlb');
|
||||
const { computeStreaks } = require('../../src/services/streaksService');
|
||||
const streaks = computeStreaks(roster, 'mlb');
|
||||
const hit = streaks.find((s) => s.type === 'hit_streak');
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit.currentStreak).toBe(2);
|
||||
});
|
||||
|
||||
test('NBA box-score rows are consumed without _raw flattening', async () => {
|
||||
routeScan({
|
||||
'gamelogs:nba:*': [],
|
||||
'tank01:nba:boxscore:*': ['tank01:nba:boxscore:20260612_NYK@SA'],
|
||||
});
|
||||
store['tank01:nba:boxscore:20260612_NYK@SA'] = [
|
||||
{ playerId: 'W1', name: 'Wemby', team: 'SA', pts: 30, reb: 12, ast: 3, threes: 2, blk: 4, stl: 1 },
|
||||
];
|
||||
const roster = await loadRosterLogs('nba');
|
||||
expect(roster[0].games[0].pts).toBe(30);
|
||||
});
|
||||
|
||||
test('gamelogs path still wins over box scores when present', async () => {
|
||||
routeScan({ 'gamelogs:mlb:*': ['gamelogs:mlb:Star:20'] });
|
||||
store['gamelogs:mlb:Star:20'] = [{ hits: 3 }, { hits: 2 }];
|
||||
const roster = await loadRosterLogs('mlb');
|
||||
expect(roster[0].name).toBe('Star');
|
||||
expect(roster[0].games).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('boxScoreKeyDate extracts the date for recency ordering', () => {
|
||||
expect(__internals.boxScoreKeyDate('tank01:mlb:boxscore:20260612_ARI@CIN')).toBe('20260612');
|
||||
expect(__internals.boxScoreKeyDate('tank01:mlb:boxscore:weird')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user