feat: Feature 1.1 — Odds API integration complete, 28 tests passing

This commit is contained in:
Kev
2026-03-21 08:31:15 -04:00
parent f70db389e2
commit 00409fd6cd
16 changed files with 6896 additions and 6 deletions
+196
View File
@@ -0,0 +1,196 @@
const { normalizeProps, MARKET_MAP, ALLOWED_BOOKS } = require('../../src/utils/oddsNormalizer');
function makeEvent(overrides = {}) {
return {
id: 'event-1',
sport_key: 'basketball_nba',
home_team: 'Denver Nuggets',
away_team: 'Los Angeles Lakers',
commence_time: '2026-03-21T19:00:00Z',
bookmakers: [],
...overrides,
};
}
function makeBookmaker(key, markets) {
return { key, title: key, markets };
}
function makeMarket(marketKey, outcomes, lastUpdate = '2026-03-21T14:28:00Z') {
return { key: marketKey, last_update: lastUpdate, outcomes };
}
function makeOutcome(name, player, price, point) {
return { name, description: player, price, point };
}
describe('oddsNormalizer', () => {
describe('normalizeProps', () => {
it('normalizes a raw response with multiple books and markets', () => {
const event = makeEvent({
bookmakers: [
makeBookmaker('draftkings', [
makeMarket('player_points', [
makeOutcome('Over', 'Nikola Jokic', -110, 26.5),
makeOutcome('Under', 'Nikola Jokic', -110, 26.5),
]),
]),
makeBookmaker('fanduel', [
makeMarket('player_points', [
makeOutcome('Over', 'Nikola Jokic', -105, 27.0),
makeOutcome('Under', 'Nikola Jokic', -115, 27.0),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
player: 'Nikola Jokic',
home_team: 'DEN',
away_team: 'LAL',
stat_type: 'points',
book: 'draftkings',
line: 26.5,
over_odds: -110,
under_odds: -110,
});
expect(result[1]).toMatchObject({
player: 'Nikola Jokic',
book: 'fanduel',
line: 27.0,
over_odds: -105,
under_odds: -115,
});
});
it('filters out books not in the allowed set', () => {
const event = makeEvent({
bookmakers: [
makeBookmaker('bovada', [
makeMarket('player_points', [
makeOutcome('Over', 'Jokic', -110, 26.5),
makeOutcome('Under', 'Jokic', -110, 26.5),
]),
]),
makeBookmaker('draftkings', [
makeMarket('player_points', [
makeOutcome('Over', 'Jokic', -110, 26.5),
makeOutcome('Under', 'Jokic', -110, 26.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(1);
expect(result[0].book).toBe('draftkings');
});
it('maps all 8 market keys to correct internal stat_types', () => {
const markets = Object.entries(MARKET_MAP);
const bookmaker = makeBookmaker(
'draftkings',
markets.map(([key]) =>
makeMarket(key, [
makeOutcome('Over', 'Test Player', -110, 10.5),
makeOutcome('Under', 'Test Player', -110, 10.5),
])
)
);
const event = makeEvent({ bookmakers: [bookmaker] });
const result = normalizeProps([event]);
const statTypes = result.map((p) => p.stat_type);
const expected = Object.values(MARKET_MAP);
expect(statTypes).toEqual(expected);
});
it('handles missing/null odds gracefully (skips incomplete outcomes)', () => {
const event = makeEvent({
bookmakers: [
makeBookmaker('draftkings', [
makeMarket('player_points', [
// Missing description
{ name: 'Over', description: null, price: -110, point: 26.5 },
// Missing point
{ name: 'Over', description: 'Jokic', price: -110, point: null },
// Valid pair
makeOutcome('Over', 'LeBron James', -110, 25.5),
makeOutcome('Under', 'LeBron James', -110, 25.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(1);
expect(result[0].player).toBe('LeBron James');
});
it('returns empty array for empty input', () => {
expect(normalizeProps([])).toEqual([]);
});
it('returns empty array for events with no bookmakers', () => {
const event = makeEvent({ bookmakers: undefined });
expect(normalizeProps([event])).toEqual([]);
});
it('handles an outcome with only Over (no Under pair)', () => {
const event = makeEvent({
bookmakers: [
makeBookmaker('fanduel', [
makeMarket('player_points', [
makeOutcome('Over', 'Solo Player', -110, 20.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(1);
expect(result[0].over_odds).toBe(-110);
expect(result[0].under_odds).toBeNull();
});
it('uses UTC timestamps from the API as fetched_at', () => {
const ts = '2026-03-21T18:00:00Z';
const event = makeEvent({
bookmakers: [
makeBookmaker('betmgm', [
makeMarket('player_points', [
makeOutcome('Over', 'Player A', -110, 10.5),
makeOutcome('Under', 'Player A', -110, 10.5),
], ts),
]),
],
});
const result = normalizeProps([event]);
expect(result[0].fetched_at).toBe(ts);
});
it('maps team names to 3-letter abbreviations', () => {
const event = makeEvent({
home_team: 'Golden State Warriors',
away_team: 'Phoenix Suns',
bookmakers: [
makeBookmaker('draftkings', [
makeMarket('player_points', [
makeOutcome('Over', 'Steph Curry', -110, 28.5),
makeOutcome('Under', 'Steph Curry', -110, 28.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result[0].home_team).toBe('GSW');
expect(result[0].away_team).toBe('PHX');
});
});
});
+182
View File
@@ -0,0 +1,182 @@
const { getOdds, getCacheKey, getQuotaKey, updateQuota, getQuotaRemaining, CACHE_TTL } = require('../../src/services/oddsService');
// Mock Redis
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
hset: jest.fn(),
hgetall: jest.fn(),
expire: jest.fn(),
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
}));
// Mock axios
jest.mock('axios');
const axios = require('axios');
// Set API key for tests
process.env.ODDS_API_KEY = 'test-api-key';
const MOCK_EVENT = {
id: 'event-1',
sport_key: 'basketball_nba',
home_team: 'Denver Nuggets',
away_team: 'Los Angeles Lakers',
commence_time: '2026-03-21T19:00:00Z',
};
const MOCK_EVENT_WITH_ODDS = {
...MOCK_EVENT,
bookmakers: [
{
key: 'draftkings',
title: 'DraftKings',
markets: [
{
key: 'player_points',
last_update: '2026-03-21T14:28:00Z',
outcomes: [
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
],
},
],
},
],
};
function mockAxiosSuccess() {
axios.get
.mockResolvedValueOnce({
data: [MOCK_EVENT],
headers: { 'x-requests-remaining': '490', 'x-requests-used': '10' },
})
.mockResolvedValueOnce({
data: MOCK_EVENT_WITH_ODDS,
headers: { 'x-requests-remaining': '489', 'x-requests-used': '11' },
});
}
beforeEach(() => {
jest.clearAllMocks();
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockResolvedValue('OK');
mockRedis.hset.mockResolvedValue(1);
mockRedis.hgetall.mockResolvedValue({});
mockRedis.expire.mockResolvedValue(1);
});
describe('oddsService', () => {
describe('getOdds - cache hit', () => {
it('returns cached data when cache is fresh (no API call made)', async () => {
const cachedData = {
updated_at: '2026-03-21T14:00:00Z',
props: [{ player: 'Jokic', stat_type: 'points' }],
};
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
mockRedis.hgetall.mockResolvedValue({ remaining: '450' });
const result = await getOdds('nba');
expect(result.source).toBe('cache');
expect(result.props).toEqual(cachedData.props);
expect(result.quota_remaining).toBe(450);
expect(axios.get).not.toHaveBeenCalled();
});
});
describe('getOdds - cache miss', () => {
it('calls Odds API when cache is empty', async () => {
mockAxiosSuccess();
const result = await getOdds('nba');
expect(result.source).toBe('live');
expect(result.props.length).toBeGreaterThan(0);
expect(result.props[0].player).toBe('Nikola Jokic');
expect(axios.get).toHaveBeenCalledTimes(2); // events + 1 event odds
});
it('stores normalized data in Redis after successful fetch', async () => {
mockAxiosSuccess();
await getOdds('nba');
expect(mockRedis.set).toHaveBeenCalledWith(
expect.stringMatching(/^odds:nba:/),
expect.any(String),
'EX',
CACHE_TTL
);
});
});
describe('getOdds - API failure', () => {
it('returns stale cache on API failure', async () => {
// First call: no cache -> will try API
mockRedis.get
.mockResolvedValueOnce(null) // initial cache check
.mockResolvedValueOnce(JSON.stringify({ // stale cache fallback
updated_at: '2026-03-21T12:00:00Z',
props: [{ player: 'Stale Jokic' }],
}));
axios.get.mockRejectedValue(new Error('API timeout'));
const result = await getOdds('nba');
expect(result.source).toBe('cache');
expect(result.stale).toBe(true);
expect(result.props[0].player).toBe('Stale Jokic');
});
it('throws 503 when API fails and no cache exists', async () => {
mockRedis.get.mockResolvedValue(null);
axios.get.mockRejectedValue(new Error('API down'));
await expect(getOdds('nba')).rejects.toMatchObject({
message: 'Odds service unavailable.',
statusCode: 503,
});
});
});
describe('getOdds - quota management', () => {
it('tracks quota from response headers', async () => {
mockAxiosSuccess();
await getOdds('nba');
expect(mockRedis.hset).toHaveBeenCalledWith(
expect.stringMatching(/^odds:quota:/),
'remaining', '489',
'used', '11',
'last_checked', expect.any(String)
);
});
it('blocks fetches when quota is 0', async () => {
mockRedis.hgetall.mockResolvedValue({ remaining: '0' });
await expect(getOdds('nba')).rejects.toMatchObject({
message: 'Odds data temporarily unavailable. Try again later.',
statusCode: 429,
});
expect(axios.get).not.toHaveBeenCalled();
});
});
describe('utility functions', () => {
it('getCacheKey uses UTC date', () => {
const key = getCacheKey('nba');
expect(key).toMatch(/^odds:nba:\d{4}-\d{2}-\d{2}$/);
});
it('getQuotaKey uses UTC year-month', () => {
const key = getQuotaKey();
expect(key).toMatch(/^odds:quota:\d{4}-\d{2}$/);
});
});
});