// Integration: /api/gamelines/:sport (Session 23). // // The Tank01 adapters are mocked — we assert the route normalizes the // book-by-book body, parses teams from the gameID, handles the missing // API-key path gracefully, and never 500s on adapter failure. const express = require('express'); const request = require('supertest'); jest.mock('../../src/services/adapters/tank01NbaAdapter', () => ({ getNBABettingOdds: jest.fn(), hasApiKey: jest.fn(() => true), })); jest.mock('../../src/services/adapters/tank01MlbAdapter', () => ({ getMLBBettingOdds: jest.fn(), hasApiKey: jest.fn(() => true), })); jest.mock('../../src/services/scheduleService', () => ({ todayET: () => '2026-06-12', })); const nbaAdapter = require('../../src/services/adapters/tank01NbaAdapter'); const mlbAdapter = require('../../src/services/adapters/tank01MlbAdapter'); function mountApp() { delete require.cache[require.resolve('../../src/routes/gameLines')]; const routes = require('../../src/routes/gameLines'); const app = express(); app.use('/api/gamelines', routes); 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' } }, ], }, }; beforeEach(() => { jest.clearAllMocks(); // clearAllMocks wipes call history but not custom implementations set via // mockReturnValue in a prior test — restore the configured-key default. nbaAdapter.hasApiKey.mockReturnValue(true); mlbAdapter.hasApiKey.mockReturnValue(true); }); describe('GET /api/gamelines/:sport', () => { 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); expect(res.body.source).toBe('tank01'); 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 () => { nbaAdapter.getNBABettingOdds.mockResolvedValue({}); const res = await request(mountApp()).get('/api/gamelines/nba'); expect(res.status).toBe(200); expect(res.body.games).toEqual({}); }); test('missing RAPID_API_KEY → graceful, configured:false, not a crash', async () => { mlbAdapter.hasApiKey.mockReturnValue(false); const res = await request(mountApp()).get('/api/gamelines/mlb'); expect(res.status).toBe(200); expect(res.body.configured).toBe(false); expect(mlbAdapter.getMLBBettingOdds).not.toHaveBeenCalled(); }); test('adapter throwing → empty games, 200 not 500', async () => { mlbAdapter.getMLBBettingOdds.mockRejectedValue(new Error('rapidapi 429')); const res = await request(mountApp()).get('/api/gamelines/mlb'); expect(res.status).toBe(200); expect(res.body.games).toEqual({}); }); test('unsupported sport → 404', async () => { const res = await request(mountApp()).get('/api/gamelines/soccer'); expect(res.status).toBe(404); }); 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'); expect(mlbAdapter.getMLBBettingOdds).toHaveBeenCalledTimes(1); }); });