const request = require('supertest'); const mockGetOdds = jest.fn(); jest.mock('../../src/services/oddsService', () => { const actual = jest.requireActual('../../src/services/oddsService'); return { ...actual, getOdds: (...args) => mockGetOdds(...args), }; }); const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() }; jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis, cacheGet: async () => null, cacheSet: async () => true, cacheDel: async () => true, isDegraded: () => false, })); const { SOCCER_SPORT_KEYS } = require('../../src/services/oddsService'); const app = require('../../src/app'); beforeEach(() => { mockGetOdds.mockReset(); mockRedis.get.mockResolvedValue(null); }); describe('SOCCER_SPORT_KEYS export', () => { test('contains all 9 launch leagues', () => { expect(SOCCER_SPORT_KEYS).toEqual(expect.arrayContaining([ 'soccer_wc', 'soccer_epl', 'soccer_laliga', 'soccer_bundesliga', 'soccer_seriea', 'soccer_ligue1', 'soccer_ucl', 'soccer_mls', 'soccer_ligamx', ])); expect(SOCCER_SPORT_KEYS).toHaveLength(9); }); }); describe('GET /api/odds/soccer/:league', () => { test('valid league reaches getOdds with the prefixed key', async () => { mockGetOdds.mockResolvedValueOnce({ props: [], updated_at: '2026-06-15T00:00:00Z', source: 'live', quota_remaining: 4000, }); const res = await request(app).get('/api/odds/soccer/wc').expect(200); expect(mockGetOdds).toHaveBeenCalledWith('soccer_wc'); expect(res.body.sport).toBe('soccer_wc'); expect(Array.isArray(res.body.props)).toBe(true); }); test('unknown league returns 400 with valid-list hint', async () => { const res = await request(app).get('/api/odds/soccer/spaceleague').expect(400); expect(res.body.error).toMatch(/Unknown soccer league/); expect(res.body.error).toMatch(/wc/); expect(mockGetOdds).not.toHaveBeenCalled(); }); test('EPL route works (proves it is not WC-only)', async () => { mockGetOdds.mockResolvedValueOnce({ props: [{ player: 'X', stat_type: 'goals', line: 0.5 }], updated_at: '2026-06-15T00:00:00Z', source: 'cache', }); const res = await request(app).get('/api/odds/soccer/epl').expect(200); expect(mockGetOdds).toHaveBeenCalledWith('soccer_epl'); expect(res.body.sport).toBe('soccer_epl'); }); test('case-insensitive league path', async () => { mockGetOdds.mockResolvedValueOnce({ props: [], updated_at: 't', source: 'cache' }); await request(app).get('/api/odds/soccer/LIGAMX').expect(200); expect(mockGetOdds).toHaveBeenCalledWith('soccer_ligamx'); }); test('getOdds throwing surfaces as a status code, not a 500 leak', async () => { const err = new Error('Odds data temporarily unavailable.'); err.statusCode = 429; mockGetOdds.mockRejectedValueOnce(err); const res = await request(app).get('/api/odds/soccer/wc').expect(429); expect(res.body.error).toMatch(/temporarily unavailable/); }); }); describe('existing NBA/NCAAB routes still work (no regression)', () => { test('/api/odds/nba still returns the NBA shape', async () => { mockGetOdds.mockResolvedValueOnce({ props: [], updated_at: 't', source: 'cache', quota_remaining: 4000, }); const res = await request(app).get('/api/odds/nba').expect(200); expect(res.body.sport).toBe('nba'); expect(mockGetOdds).toHaveBeenCalledWith('nba'); }); });