process.env.SPORT = 'nba'; process.env.VYNDR_INTERNAL_KEY = 'unit-test-internal-key'; process.env.VYNDR_API_URL = 'http://localhost:3001'; process.env.BUFFER_MS = '5'; // tests can't afford the real 30s const mockAxios = { get: jest.fn(), post: jest.fn() }; jest.mock('axios', () => ({ get: (...args) => mockAxios.get(...args), post: (...args) => mockAxios.post(...args), })); const mockCache = { current: new Map() }; const mockRedisSet = jest.fn(); jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => mockCache.current.get(k) ?? null, cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; }, cacheDel: async (k) => { mockCache.current.delete(k); return true; }, getRedisClient: () => ({ set: (...args) => mockRedisSet(...args) }), })); const mockBatchCapture = jest.fn(); jest.mock('../../src/services/adapters/oddsPapiAdapter', () => ({ batchCapture: (...args) => mockBatchCapture(...args), configured: () => true, })); // The poller-internal limiters carry state between tests and the modest // 2-tokens-per-minute ESPN budget exhausts within three test cases. Replace // with a no-op so tests run in parallel without blocking on refill. jest.mock('../../src/utils/rateLimiter', () => ({ createLimiter: () => ({ waitForToken: async () => true, snapshot: () => ({}) }), createCircuitBreaker: () => ({ call: async (fn) => fn(), snapshot: () => ({}) }), API_BUDGETS: { sharpApi: { tokensPerInterval: 100, interval: 60_000 }, espn: { tokensPerInterval: 100, interval: 60_000 }, mlbStats: { tokensPerInterval: 100, interval: 60_000 }, oddsPapi: { tokensPerInterval: 100, interval: 60_000 }, openRouter: { tokensPerInterval: 100, interval: 60_000 }, }, })); const poller = require('../../poller/poller'); const { getSportConfig } = require('../../src/config/sports'); beforeEach(() => { mockAxios.get.mockReset(); mockAxios.post.mockReset(); mockBatchCapture.mockReset(); mockRedisSet.mockReset(); mockCache.current.clear(); mockRedisSet.mockResolvedValue('OK'); }); describe('poller helpers', () => { test('isFinalStatus accepts FINAL, FINAL_OT, FINAL_SHOOTOUT', () => { expect(poller.isFinalStatus('STATUS_FINAL')).toBe(true); expect(poller.isFinalStatus('STATUS_FINAL_OT')).toBe(true); expect(poller.isFinalStatus('STATUS_FINAL_SHOOTOUT')).toBe(true); expect(poller.isFinalStatus('STATUS_HALFTIME')).toBe(false); }); test('isVoidStatus catches postponed + canceled (both spellings)', () => { expect(poller.isVoidStatus('STATUS_POSTPONED')).toBe(true); expect(poller.isVoidStatus('STATUS_CANCELED')).toBe(true); expect(poller.isVoidStatus('STATUS_CANCELLED')).toBe(true); expect(poller.isVoidStatus('STATUS_FINAL')).toBe(false); }); test('inGameHours wraps past midnight when gameEndHourET >= 24', () => { // Test logic indirectly with a stub configurable hour. Test that // configured ranges include reasonable hours. const ncaab = getSportConfig('ncaab'); expect(ncaab.gameStartHourET).toBeLessThan(ncaab.gameEndHourET); }); test('validateBoxScore — basketball valid', () => { const valid = { boxscore: { players: [ { statistics: [{ athletes: [] }] }, { statistics: [{ athletes: [] }] }, ], }, }; expect(poller.validateBoxScore(valid, getSportConfig('nba'))).toMatchObject({ valid: true }); }); test('validateBoxScore — rejects empty data + missing players', () => { expect(poller.validateBoxScore(null, getSportConfig('nba'))).toMatchObject({ valid: false }); expect(poller.validateBoxScore({ boxscore: {} }, getSportConfig('nba'))).toMatchObject({ valid: false }); }); test('validateBoxScore — MLB Stats API shape', () => { const valid = { liveData: { boxscore: { teams: { home: {}, away: {} } } } }; expect(poller.validateBoxScore(valid, getSportConfig('mlb'))).toMatchObject({ valid: true }); }); }); describe('handleGame — status transitions', () => { test('STATUS_IN_PROGRESS first sighting triggers OddsPapi capture', async () => { const sportCfg = getSportConfig('nba'); await poller.handleGame( { id: 'game-tip', name: 'STATUS_IN_PROGRESS', competitions: [{}] }, sportCfg, ); expect(mockBatchCapture).toHaveBeenCalledWith('nba', 'game-tip'); }); test('STATUS_IN_PROGRESS second sighting does NOT trigger capture again', async () => { const sportCfg = getSportConfig('nba'); mockCache.current.set('game:gx:status', 'STATUS_IN_PROGRESS'); await poller.handleGame({ id: 'gx', name: 'STATUS_IN_PROGRESS' }, sportCfg); expect(mockBatchCapture).not.toHaveBeenCalled(); }); test('STATUS_FINAL triggers POST to /api/grading/resolve', async () => { const sportCfg = getSportConfig('nba'); mockAxios.get.mockResolvedValue({ status: 200, data: { boxscore: { players: [{ statistics: [{ athletes: [] }] }, { statistics: [{ athletes: [] }] }], }, }, }); mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 5, voided: 0 } }); await poller.handleGame({ id: 'gf', name: 'STATUS_FINAL' }, sportCfg); expect(mockAxios.post).toHaveBeenCalledWith( 'http://localhost:3001/api/grading/resolve', expect.objectContaining({ gameId: 'gf', sport: 'nba' }), expect.objectContaining({ headers: expect.objectContaining({ 'X-VYNDR-Internal-Key': 'unit-test-internal-key' }), }), ); }); test('STATUS_FINAL_OT also triggers resolution', async () => { const sportCfg = getSportConfig('nba'); mockAxios.get.mockResolvedValue({ status: 200, data: { boxscore: { players: [{ statistics: [{}] }, { statistics: [{}] }] } }, }); mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 3, voided: 0 } }); await poller.handleGame({ id: 'g-ot', name: 'STATUS_FINAL_OT' }, sportCfg); expect(mockAxios.post).toHaveBeenCalled(); }); test('STATUS_POSTPONED sends a void payload', async () => { mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 0, voided: 7 } }); await poller.handleGame({ id: 'pp', name: 'STATUS_POSTPONED' }, getSportConfig('nba')); expect(mockAxios.post).toHaveBeenCalledWith( 'http://localhost:3001/api/grading/resolve', expect.objectContaining({ void: true, reason: 'status_postponed' }), expect.anything(), ); }); test('lock NX prevents double resolution', async () => { const sportCfg = getSportConfig('nba'); mockRedisSet.mockResolvedValue(null); // simulates someone else holding the lock mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 0 } }); await poller.handleGame({ id: 'locked', name: 'STATUS_FINAL' }, sportCfg); expect(mockAxios.post).not.toHaveBeenCalled(); }); test('invalid box score blocks the POST + sends ntfy', async () => { const sportCfg = getSportConfig('nba'); mockAxios.get.mockResolvedValue({ status: 200, data: { boxscore: { players: [] } } }); await poller.handleGame({ id: 'bad-box', name: 'STATUS_FINAL' }, sportCfg); expect(mockAxios.post.mock.calls.filter((c) => c[0].endsWith('/api/grading/resolve'))).toHaveLength(0); }); test('VYNDR_INTERNAL_KEY is never inlined into logs', () => { // The poller source must not console.log the key directly. const fs = require('fs'); const src = fs.readFileSync(require.resolve('../../poller/poller.js'), 'utf8'); expect(src).not.toMatch(/console\.[a-z]+\([^)]*VYNDR_INTERNAL_KEY/); }); }); describe('postResolution retry', () => { test('returns successful payload on first try', async () => { mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 4 } }); const res = await poller.postResolution({ gameId: 'g1', sport: 'nba', boxScore: {} }); expect(res).toMatchObject({ resolved: 4 }); }); });