Files

188 lines
7.7 KiB
JavaScript

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 });
});
});