188 lines
7.7 KiB
JavaScript
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 });
|
|
});
|
|
});
|